mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 18:52:39 +00:00
Compare commits
279
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00870740ef | ||
|
|
ebf8a21c08 | ||
|
|
515ba62be6 | ||
|
|
32a20cbeea | ||
|
|
9877936d13 | ||
|
|
1a473ed7f3 | ||
|
|
3832053736 | ||
|
|
b01e274cce | ||
|
|
92e701be8f | ||
|
|
c2c990e763 | ||
|
|
76b9f89f68 | ||
|
|
970bf828b3 | ||
|
|
dfc814147a | ||
|
|
831d990ef7 | ||
|
|
e09fd41adc | ||
|
|
8d32167826 | ||
|
|
1cfae98bf5 | ||
|
|
4cbfe95473 | ||
|
|
242f61937a | ||
|
|
d913c4a9b7 | ||
|
|
2cd296e38f | ||
|
|
95c1d1fe0f | ||
|
|
33ac657fed | ||
|
|
067125bd4f | ||
|
|
93095aa1d2 | ||
|
|
ac45f23213 | ||
|
|
b91749a316 | ||
|
|
a6423f7f0a | ||
|
|
403fb03c97 | ||
|
|
f8411d121e | ||
|
|
9ea75afd37 | ||
|
|
006894274e | ||
|
|
951fadc104 | ||
|
|
8600f183b1 | ||
|
|
002946d4dd | ||
|
|
fb02bc8795 | ||
|
|
53baa16bee | ||
|
|
67eddbd6a9 | ||
|
|
044546ae0c | ||
|
|
bd748b6edc | ||
|
|
427664dfa7 | ||
|
|
a0169bf7f4 | ||
|
|
79968ee02d | ||
|
|
afe0eaab5b | ||
|
|
04465f124c | ||
|
|
86b83c3366 | ||
|
|
4a5dd986e1 | ||
|
|
64065061c5 | ||
|
|
7b9636a22d | ||
|
|
8165de7b06 | ||
|
|
75f83b0b9b | ||
|
|
badec6f587 | ||
|
|
5a2708653c | ||
|
|
06b3fc2ec6 | ||
|
|
bd1fb311ea | ||
|
|
8991d34f78 | ||
|
|
128d6668ae | ||
|
|
d9d6509880 | ||
|
|
2d9cbb0453 | ||
|
|
e373c33108 | ||
|
|
6a36db9924 | ||
|
|
d3393b434a | ||
|
|
43bbf8cb12 | ||
|
|
cfa3b009e3 | ||
|
|
f411a2a383 | ||
|
|
c4d84ff2a3 | ||
|
|
73b0b60bdd | ||
|
|
5a232d8cf1 | ||
|
|
2238171955 | ||
|
|
fbc2371b95 | ||
|
|
f8815bf1c2 | ||
|
|
93cda8e60f | ||
|
|
c53d42acf8 | ||
|
|
2a519d96e5 | ||
|
|
07714773cc | ||
|
|
ada623c1c6 | ||
|
|
9fb0ab9b2d | ||
|
|
83bbc8f659 | ||
|
|
7819019cea | ||
|
|
23b20169ef | ||
|
|
e5b0ea6d1b | ||
|
|
4de4e90325 | ||
|
|
fa41ba76cd | ||
|
|
9f6570ca3e | ||
|
|
ccb956f98f | ||
|
|
f483e62231 | ||
|
|
c0bb34c514 | ||
|
|
6b5282ad43 | ||
|
|
72e4476acc | ||
|
|
5d76cc8840 | ||
|
|
b0e0786419 | ||
|
|
4d6a154f6e | ||
|
|
5c3cc99d8c | ||
|
|
14cc1cc83c | ||
|
|
82f97e942d | ||
|
|
59d9b2ebdd | ||
|
|
d8ff049aff | ||
|
|
f08bff1b2d | ||
|
|
63a46acaf1 | ||
|
|
aac780ddf7 | ||
|
|
49d5426059 | ||
|
|
d6585f7c3a | ||
|
|
ed2dc08443 | ||
|
|
4b82ca77c8 | ||
|
|
07d10f55f9 | ||
|
|
13bfdd8cc1 | ||
|
|
9a8b01a738 | ||
|
|
adf2f5921f | ||
|
|
3838a8b969 | ||
|
|
11524dc349 | ||
|
|
a001c72726 | ||
|
|
8344296789 | ||
|
|
40f8a8414a | ||
|
|
9386901896 | ||
|
|
6390f8d995 | ||
|
|
daefa8fb7f | ||
|
|
981b94fa56 | ||
|
|
827092a246 | ||
|
|
213767e3d5 | ||
|
|
6ffffc38a0 | ||
|
|
270c12d524 | ||
|
|
53ed34bacf | ||
|
|
e6bf64aed8 | ||
|
|
a5582e0ee0 | ||
|
|
754f1facb6 | ||
|
|
e70fa2dc84 | ||
|
|
cbb690efd0 | ||
|
|
21742bbeb4 | ||
|
|
5a20e78dff | ||
|
|
e6044264e2 | ||
|
|
beeb315c8d | ||
|
|
e635c6beca | ||
|
|
60cc73f87e | ||
|
|
43bec954ea | ||
|
|
751a1df682 | ||
|
|
79ddf6980a | ||
|
|
9d30b7e85a | ||
|
|
326dad93e4 | ||
|
|
c80d7a8165 | ||
|
|
89db6d3187 | ||
|
|
d55209b81e | ||
|
|
0f0efa1707 | ||
|
|
fb24029c69 | ||
|
|
7cba6cf414 | ||
|
|
16386ba6d7 | ||
|
|
9c90657d60 | ||
|
|
d0be7c3ee7 | ||
|
|
1e0f62976d | ||
|
|
5241727b29 | ||
|
|
7652ee10ca | ||
|
|
612979f95f | ||
|
|
d480101115 | ||
|
|
795c3cac97 | ||
|
|
4d6774f0c2 | ||
|
|
d888da8591 | ||
|
|
74a65bd851 | ||
|
|
4d5e8d870a | ||
|
|
95ea02a87f | ||
|
|
e647c61fb5 | ||
|
|
19585709d5 | ||
|
|
369b46ea08 | ||
|
|
25ab97fe8b | ||
|
|
1f13eee4a7 | ||
|
|
cc6993042f | ||
|
|
3f0c9f2679 | ||
|
|
bc53cf0a04 | ||
|
|
367d945954 | ||
|
|
c54e2ccabe | ||
|
|
750d38d395 | ||
|
|
84600609f0 | ||
|
|
4190a0aa72 | ||
|
|
43a9606c55 | ||
|
|
e3962e1753 | ||
|
|
8b1fbb7b86 | ||
|
|
149a415016 | ||
|
|
28f45a41a5 | ||
|
|
aa1d33f0a0 | ||
|
|
3b8cfcbec6 | ||
|
|
2bbdc7ed86 | ||
|
|
404b3b1dbf | ||
|
|
7f63fc6ceb | ||
|
|
944978ddb6 | ||
|
|
a27858e743 | ||
|
|
8789ef4ad8 | ||
|
|
321ffce6a3 | ||
|
|
78b7e51396 | ||
|
|
0deaed3794 | ||
|
|
18adfb4f1d | ||
|
|
4176bd8aa1 | ||
|
|
4215120d7c | ||
|
|
e6dec3fc1a | ||
|
|
b1b85833bb | ||
|
|
c7399084a4 | ||
|
|
a236bd4515 | ||
|
|
aa59e67df5 | ||
|
|
96f736fa6e | ||
|
|
1b309ae0c9 | ||
|
|
fa2f501dc7 | ||
|
|
02a7a170f6 | ||
|
|
56fdc47d9c | ||
|
|
74f7b9e416 | ||
|
|
ce77947654 | ||
|
|
0f0bfb551c | ||
|
|
f4b487df78 | ||
|
|
2a5ac7a013 | ||
|
|
4068554532 | ||
|
|
9c8bc2e208 | ||
|
|
d69d5c92ce | ||
|
|
b8e048c7fa | ||
|
|
75542c10d8 | ||
|
|
0e3e9bcd0c | ||
|
|
4786bbbb0b | ||
|
|
4f58f889d6 | ||
|
|
d389200dc3 | ||
|
|
c1420aff3c | ||
|
|
9cb0358fae | ||
|
|
65b3779da5 | ||
|
|
7a9348a5fd | ||
|
|
397d454de0 | ||
|
|
5cf806dc77 | ||
|
|
529e743f1e | ||
|
|
cefc189331 | ||
|
|
666d3fe807 | ||
|
|
65f8823e4e | ||
|
|
07618f248f | ||
|
|
7b19dd736f | ||
|
|
7173342bcb | ||
|
|
6e5cad812e | ||
|
|
5d1b3383d7 | ||
|
|
f75e28a77f | ||
|
|
eabd561fde | ||
|
|
339023548d | ||
|
|
6eb26f8c6c | ||
|
|
fc55eaf12b | ||
|
|
5d09f7449e | ||
|
|
dc772021a8 | ||
|
|
93b2199c56 | ||
|
|
8d4d445c85 | ||
|
|
d3e5d6929d | ||
|
|
6362d429c7 | ||
|
|
c6e87744f1 | ||
|
|
8f83876a3c | ||
|
|
25213ce83a | ||
|
|
ce821afd27 | ||
|
|
cc6b0f80be | ||
|
|
5f0b6e7146 | ||
|
|
76a9c6f950 | ||
|
|
4dca672711 | ||
|
|
8e482b1613 | ||
|
|
2dd597f5f5 | ||
|
|
fcfb9c99da | ||
|
|
5e1b24b217 | ||
|
|
2ca7dd71e2 | ||
|
|
449a4b48d2 | ||
|
|
5d5a79a2a8 | ||
|
|
59bc3bd3d1 | ||
|
|
86cee8e43a | ||
|
|
c4c17df022 | ||
|
|
34c27fe77e | ||
|
|
b730d83943 | ||
|
|
f1ebe518f8 | ||
|
|
68c8efc5d3 | ||
|
|
73e6a42141 | ||
|
|
060f3d457c | ||
|
|
255270b5ee | ||
|
|
59cb4d7499 | ||
|
|
d75dd33773 | ||
|
|
f1ffecfcd4 | ||
|
|
a7d5ab2497 | ||
|
|
d8243bfb72 | ||
|
|
5b3ec572cb | ||
|
|
806079c7ad | ||
|
|
1f6ffa9e0e | ||
|
|
266d042528 | ||
|
|
e026223c80 | ||
|
|
2190910e6f | ||
|
|
0d3f59feed | ||
|
|
edcd2fe5d5 | ||
|
|
7d02e24d84 |
@@ -43,6 +43,9 @@ jobs:
|
|||||||
- name: Build relay dependency
|
- name: Build relay dependency
|
||||||
run: npm run build --workspace=@getpaseo/relay
|
run: npm run build --workspace=@getpaseo/relay
|
||||||
|
|
||||||
|
- name: Build server dependency
|
||||||
|
run: npm run build --workspace=@getpaseo/server
|
||||||
|
|
||||||
- name: Typecheck all packages
|
- name: Typecheck all packages
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
- '!v*-rc.*'
|
- '!v*-beta.*'
|
||||||
- 'app-v*'
|
- 'app-v*'
|
||||||
- '!app-v*-rc.*'
|
- '!app-v*-beta.*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ jobs:
|
|||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--title "Paseo $RELEASE_TAG" \
|
--title "Paseo $RELEASE_TAG" \
|
||||||
--generate-notes \
|
--generate-notes \
|
||||||
$prerelease_flag
|
$prerelease_flag || {
|
||||||
|
echo "Release creation raced with another workflow; continuing."
|
||||||
|
}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
publish-macos:
|
publish-macos:
|
||||||
@@ -145,6 +147,7 @@ jobs:
|
|||||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||||
publish_mode="always"
|
publish_mode="always"
|
||||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
|
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
|
||||||
@@ -154,7 +157,7 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: mac-manifest-${{ matrix.electron_arch }}
|
name: mac-manifest-${{ matrix.electron_arch }}
|
||||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/latest-mac.yml
|
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-mac.yml
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
finalize-mac-manifest:
|
finalize-mac-manifest:
|
||||||
@@ -227,8 +230,9 @@ jobs:
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8');
|
const manifestName = `${process.env.RELEASE_CHANNEL}-mac.yml`;
|
||||||
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8');
|
const arm64Text = fs.readFileSync(`mac-manifest-arm64/${manifestName}`, 'utf8');
|
||||||
|
const x64Text = fs.readFileSync(`mac-manifest-x64/${manifestName}`, 'utf8');
|
||||||
|
|
||||||
const arm64 = parseManifest(arm64Text);
|
const arm64 = parseManifest(arm64Text);
|
||||||
const x64 = parseManifest(x64Text);
|
const x64 = parseManifest(x64Text);
|
||||||
@@ -243,7 +247,7 @@ jobs:
|
|||||||
};
|
};
|
||||||
|
|
||||||
const output = toYaml(merged);
|
const output = toYaml(merged);
|
||||||
fs.writeFileSync('latest-mac.yml', output);
|
fs.writeFileSync(manifestName, output);
|
||||||
console.log('Merged manifest:\n' + output);
|
console.log('Merged manifest:\n' + output);
|
||||||
NODE
|
NODE
|
||||||
|
|
||||||
@@ -251,7 +255,7 @@ jobs:
|
|||||||
if: env.IS_SMOKE_TAG != 'true'
|
if: env.IS_SMOKE_TAG != 'true'
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
|
run: gh release upload "$RELEASE_TAG" "$RELEASE_CHANNEL-mac.yml" --clobber --repo "${{ github.repository }}"
|
||||||
|
|
||||||
publish-linux:
|
publish-linux:
|
||||||
needs: [create-release]
|
needs: [create-release]
|
||||||
@@ -316,6 +320,7 @@ jobs:
|
|||||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||||
publish_mode="always"
|
publish_mode="always"
|
||||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
|
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
||||||
@@ -391,6 +396,7 @@ jobs:
|
|||||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||||
publish_mode="always"
|
publish_mode="always"
|
||||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||||
|
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
|
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
|
||||||
|
|||||||
@@ -54,9 +54,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
create_if_missing="false"
|
create_if_missing="false"
|
||||||
if [ "$EVENT_NAME" = "push" ] && [[ "$REF" == refs/tags/v* ]]; then
|
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
|
||||||
create_if_missing="true"
|
|
||||||
elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
|
|
||||||
create_if_missing="true"
|
create_if_missing="true"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.60-beta.1 - 2026-04-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Beta release channel for desktop updates, with a Settings toggle for receiving beta builds before they are promoted to stable.
|
||||||
|
- Release candidates are now called beta releases, starting with `0.1.60-beta.1`.
|
||||||
|
|
||||||
|
## 0.1.59 - 2026-04-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Opus 4.7 in the Claude model picker, with a 1M-context variant.
|
||||||
|
- Extra High reasoning effort for Opus 4.7, between High and Max.
|
||||||
|
|
||||||
## 0.1.58 - 2026-04-16
|
## 0.1.58 - 2026-04-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@ Stable tag pushes like `v0.1.0` trigger:
|
|||||||
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
|
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
|
||||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
|
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
|
||||||
|
|
||||||
Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||||
|
|
||||||
### Useful commands
|
### Useful commands
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# Attachment-Based Review Context Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Use structured attachments as the source of truth for review context during agent creation.
|
||||||
|
|
||||||
|
This covers two related behaviors:
|
||||||
|
|
||||||
|
1. Provider-facing context for the agent prompt
|
||||||
|
2. Worktree checkout behavior when creating a worktree from a review item
|
||||||
|
|
||||||
|
The design should stay compatible with future forge support such as GitLab.
|
||||||
|
|
||||||
|
## Core Direction
|
||||||
|
|
||||||
|
- Do not parse the prompt to discover issue/PR intent.
|
||||||
|
- Do not introduce deeply nested transport objects for this flow.
|
||||||
|
- Make the attachment itself the discriminated union.
|
||||||
|
- Keep forge-specific checkout logic below the attachment layer.
|
||||||
|
- Let each agent provider translate attachments into its own prompt/input format.
|
||||||
|
|
||||||
|
## Initial Attachment Types
|
||||||
|
|
||||||
|
Two initial structured attachment types:
|
||||||
|
|
||||||
|
- `github_pr`
|
||||||
|
- `github_issue`
|
||||||
|
|
||||||
|
With separate MIME types:
|
||||||
|
|
||||||
|
- `application/github-pr`
|
||||||
|
- `application/github-issue`
|
||||||
|
|
||||||
|
Proposed wire shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type AgentAttachment =
|
||||||
|
| {
|
||||||
|
type: "github_pr";
|
||||||
|
mimeType: "application/github-pr";
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
body?: string | null;
|
||||||
|
baseRefName?: string | null;
|
||||||
|
headRefName?: string | null;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "github_issue";
|
||||||
|
mimeType: "application/github-issue";
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
body?: string | null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backward Compatibility
|
||||||
|
|
||||||
|
- Add new optional `attachments` fields; keep existing `images` fields.
|
||||||
|
- Unknown attachment discriminators must be dropped during schema normalization instead of failing the full request.
|
||||||
|
- Malformed attachment entries should also be ignored safely.
|
||||||
|
- Old clients continue working because `attachments` is optional.
|
||||||
|
- New clients can send `attachments` without breaking older flows that still rely on `images`.
|
||||||
|
|
||||||
|
## Request-Level Changes
|
||||||
|
|
||||||
|
Add optional `attachments` to:
|
||||||
|
|
||||||
|
- `create_agent_request`
|
||||||
|
- `send_agent_message_request`
|
||||||
|
|
||||||
|
Existing `initialPrompt` remains plain user text.
|
||||||
|
|
||||||
|
The selected GitHub PR/issue becomes a structured attachment instead of being injected into prompt text as the primary source of truth.
|
||||||
|
|
||||||
|
## App Responsibilities
|
||||||
|
|
||||||
|
When the user selects a GitHub item:
|
||||||
|
|
||||||
|
- If it is a PR, create a `github_pr` attachment
|
||||||
|
- If it is an issue, create a `github_issue` attachment
|
||||||
|
- Include the attachment in the create-agent request
|
||||||
|
|
||||||
|
The UI can still render friendly labels and previews from the same data.
|
||||||
|
|
||||||
|
The app should stop treating PR/issue context as prompt-only metadata.
|
||||||
|
|
||||||
|
## Worktree Creation Behavior
|
||||||
|
|
||||||
|
During agent creation, if worktree creation is requested:
|
||||||
|
|
||||||
|
- Inspect normalized attachments
|
||||||
|
- If a `github_pr` attachment is present, use it to drive checkout for the worktree
|
||||||
|
|
||||||
|
The important rule:
|
||||||
|
|
||||||
|
- attachment type identifies the review object
|
||||||
|
- server-side git logic decides how to check it out
|
||||||
|
|
||||||
|
This keeps the attachment contract simple while allowing fork-safe implementation details.
|
||||||
|
|
||||||
|
## Checkout Resolution
|
||||||
|
|
||||||
|
The attachment itself should not encode the full checkout strategy.
|
||||||
|
|
||||||
|
Instead, the server should resolve checkout from the `github_pr` attachment using forge-aware logic.
|
||||||
|
|
||||||
|
Examples of possible implementation strategies:
|
||||||
|
|
||||||
|
- `gh pr checkout`
|
||||||
|
- `git fetch origin refs/pull/<number>/head:<local-branch>`
|
||||||
|
- another GitHub-aware resolver if needed later
|
||||||
|
|
||||||
|
This is intentionally a server implementation detail, not part of the attachment schema.
|
||||||
|
|
||||||
|
## Provider Responsibilities
|
||||||
|
|
||||||
|
Each provider adapter should receive normalized attachments and decide how to represent them for the model.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Claude: render attachment into text blocks
|
||||||
|
- Codex: inject as blocks or text
|
||||||
|
- OpenCode: send as file/resource-like input where appropriate
|
||||||
|
- ACP: convert to resource/text forms as supported
|
||||||
|
|
||||||
|
The session layer should not hardcode one translation strategy for all providers.
|
||||||
|
|
||||||
|
## Suggested Implementation Order
|
||||||
|
|
||||||
|
1. Add tolerant attachment schema and normalization in shared/server message handling.
|
||||||
|
2. Thread `attachments` through app -> daemon client -> session layer.
|
||||||
|
3. Update create-agent UI flows to emit `github_pr` / `github_issue` attachments from GitHub selection.
|
||||||
|
4. Teach worktree creation to inspect attachments and special-case `github_pr`.
|
||||||
|
5. Update provider adapters to translate attachments into provider-specific prompt/input forms.
|
||||||
|
6. Optionally migrate image transport into the same attachment mechanism later.
|
||||||
|
|
||||||
|
## Files Likely Involved
|
||||||
|
|
||||||
|
- `packages/server/src/shared/messages.ts`
|
||||||
|
- `packages/server/src/client/daemon-client.ts`
|
||||||
|
- `packages/server/src/server/session.ts`
|
||||||
|
- `packages/server/src/server/worktree-session.ts`
|
||||||
|
- `packages/server/src/server/agent/agent-sdk-types.ts`
|
||||||
|
- `packages/server/src/server/agent/providers/claude-agent.ts`
|
||||||
|
- `packages/server/src/server/agent/providers/codex-app-server-agent.ts`
|
||||||
|
- `packages/server/src/server/agent/providers/opencode-agent.ts`
|
||||||
|
- `packages/server/src/server/agent/providers/acp-agent.ts`
|
||||||
|
- `packages/app/src/screens/new-workspace-screen.tsx`
|
||||||
|
- `packages/app/src/screens/agent/draft-agent-screen.tsx`
|
||||||
|
- `packages/app/src/contexts/session-context.tsx`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The current image path can remain in place initially.
|
||||||
|
- The plan intentionally keeps checkout concerns separate from provider prompt translation.
|
||||||
|
- Future forge support should add new attachment discriminators rather than expanding GitHub-only nested fields.
|
||||||
@@ -35,6 +35,52 @@ In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
|
|||||||
|
|
||||||
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
||||||
|
|
||||||
|
### Database queries
|
||||||
|
|
||||||
|
Run arbitrary SQL against the SQLite database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show table row counts
|
||||||
|
npm run db:query
|
||||||
|
|
||||||
|
# Run any SQL
|
||||||
|
npm run db:query -- "SELECT agent_id, title, last_status FROM agent_snapshots"
|
||||||
|
npm run db:query -- "SELECT agent_id, seq, item_kind FROM agent_timeline_rows ORDER BY committed_at DESC LIMIT 10"
|
||||||
|
|
||||||
|
# Point at a specific DB directory
|
||||||
|
npm run db:query -- --db /path/to/db "SELECT ..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`.
|
||||||
|
Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens the database directly in read-only mode.
|
||||||
|
|
||||||
|
## paseo.json service scripts
|
||||||
|
|
||||||
|
Every `scripts` entry with `"type": "service"` receives these environment variables:
|
||||||
|
|
||||||
|
| Variable | Value |
|
||||||
|
|---|---|
|
||||||
|
| `PASEO_SERVICE_<NAME>_URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
|
||||||
|
| `PASEO_SERVICE_<NAME>_PORT` | Raw ephemeral port for a declared peer service. Use only as a bypass escape hatch; it can go stale if that peer restarts. |
|
||||||
|
| `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
|
||||||
|
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
|
||||||
|
| `HOST` | Bind host for the service process. |
|
||||||
|
|
||||||
|
`<NAME>` is normalized from the script name by uppercasing it, replacing each run of non-`A-Z0-9` characters with `_`, and trimming leading or trailing `_`. For example, `app-server` and `app.server` both normalize to `APP_SERVER`; that collision fails at spawn time with an actionable error.
|
||||||
|
|
||||||
|
`PORT` is not injected by default. If a framework requires `PORT`, set it in the command:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"web": {
|
||||||
|
"type": "service",
|
||||||
|
"command": "PORT=$PASEO_PORT npm run dev:web"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Build sync gotchas
|
## Build sync gotchas
|
||||||
|
|
||||||
### Relay → Daemon
|
### Relay → Daemon
|
||||||
|
|||||||
@@ -95,6 +95,37 @@ Two reusable flows handle Expo dev client screens after launch:
|
|||||||
- `flows/launch.yaml` — handles dev launcher, dismisses dev menu, asserts "Welcome to Paseo"
|
- `flows/launch.yaml` — handles dev launcher, dismisses dev menu, asserts "Welcome to Paseo"
|
||||||
- `flows/dev-client.yaml` — same but without asserting a particular app route
|
- `flows/dev-client.yaml` — same but without asserting a particular app route
|
||||||
|
|
||||||
|
### Reach the composer
|
||||||
|
|
||||||
|
`flows/land-in-chat.yaml` is the canonical "get into a chat" primitive. It `clearState`s, runs `launch.yaml`, taps the welcome screen's direct-connection option, types `127.0.0.1:6767`, submits, and waits for `message-input-root`. Compose any composer-level fixture on top of it:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
appId: sh.paseo
|
||||||
|
---
|
||||||
|
- runFlow: flows/land-in-chat.yaml
|
||||||
|
# ...your scenario here, starting from a ready composer
|
||||||
|
```
|
||||||
|
|
||||||
|
See `image-picker-repro.yaml` for an example.
|
||||||
|
|
||||||
|
**Prefer direct connection over relay pairing for local E2E.** Relay needs a 400+ character pairing URL typed into an input; direct needs `127.0.0.1:6767`. The daemon listens on 6767 and the simulator can reach it directly.
|
||||||
|
|
||||||
|
### Inputs that Maestro types into
|
||||||
|
|
||||||
|
Maestro `inputText` fires one character at a time. React Native's **controlled** `TextInput` re-renders per keystroke; if a controlled input's state update lags or re-mounts mid-type, characters are dropped silently — the final value on screen is a truncated/scrambled version of what was "typed."
|
||||||
|
|
||||||
|
For inputs that E2E flows type into (host endpoint, pairing URL, etc.), use an **uncontrolled ref-backed input**: `defaultValue` + `onChangeText` writes into a `useRef`, reads via the ref on submit. No per-keystroke re-render, no dropped characters.
|
||||||
|
|
||||||
|
See `add-host-modal.tsx` and `pair-link-modal.tsx` for the pattern. Always pair the source change with a Maestro `assertVisible` on the input's `id + text` after `inputText`, so regressions are caught immediately.
|
||||||
|
|
||||||
|
### Dropdowns that launch native presenters (iOS)
|
||||||
|
|
||||||
|
On iOS, when a dropdown menu (`DropdownMenu` / RN `Modal`) item needs to launch a native presenter like `PHPickerViewController` (image picker) or a `UIDocumentPicker`, the callback **must not fire while the `Modal` is still dismissing**. UIKit dismissal completion spans multiple frames beyond React unmount; launching a native presenter mid-dismissal leaves an invisible backdrop mounted that traps every subsequent touch.
|
||||||
|
|
||||||
|
`DropdownMenu` handles this by deferring the selected item's `onSelect` until `Modal.onDismiss` fires (UIKit-level dismissal complete), then adds a small extra buffer before invoking it. See `components/ui/dropdown-menu.tsx`'s `selectItem` / `flushPendingSelect`.
|
||||||
|
|
||||||
|
When building a new component that composes a dropdown with a native presenter, reuse this dropdown — do not invent a new timing shim.
|
||||||
|
|
||||||
## Self-verification loops
|
## Self-verification loops
|
||||||
|
|
||||||
Maestro can only interact with the app UI — it can't toggle iOS appearance, change locale, or simulate network conditions. For bugs that depend on system-level state, wrap Maestro in a bash script that handles the system changes between Maestro runs.
|
Maestro can only interact with the app UI — it can't toggle iOS appearance, change locale, or simulate network conditions. For bugs that depend on system-level state, wrap Maestro in a bash script that handles the system changes between Maestro runs.
|
||||||
|
|||||||
+28
-25
@@ -7,7 +7,7 @@ All workspaces share one version and release together.
|
|||||||
There are two supported ways to ship from `main`:
|
There are two supported ways to ship from `main`:
|
||||||
|
|
||||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||||
2. **Release candidate flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
|
2. **Beta flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
|
||||||
|
|
||||||
## Standard release (patch)
|
## Standard release (patch)
|
||||||
|
|
||||||
@@ -37,31 +37,32 @@ npm run release:publish # Publish to npm
|
|||||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Release candidate flow
|
## Beta flow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag
|
npm run release:beta:patch # Bump to X.Y.Z-beta.1, push commit + tag
|
||||||
# ... test desktop and APK prerelease assets from GitHub Releases ...
|
# ... test desktop and APK prerelease assets from GitHub Releases ...
|
||||||
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ...
|
npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
|
||||||
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z
|
npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||||
```
|
```
|
||||||
|
|
||||||
- RC tags are published GitHub prereleases like `v0.1.41-rc.1`
|
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
|
||||||
- RCs publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
|
- Betas publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
|
||||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the RC tag
|
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
|
||||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||||
- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release.
|
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
|
||||||
|
- **Do create a changelog entry for betas.** The beta entry is temporary and gets updated in place until promotion.
|
||||||
|
|
||||||
Use the RC path when you need to:
|
Use the beta path when you need to:
|
||||||
|
|
||||||
- test a build manually in a Linux or Windows VM
|
- test a build manually in a Linux or Windows VM
|
||||||
- send a build to a user who is hitting a specific problem
|
- send a build to a user who is hitting a specific problem
|
||||||
- iterate on `rc.1`, `rc.2`, `rc.3`, and so on before deciding to ship broadly
|
- iterate on `beta.1`, `beta.2`, `beta.3`, and so on before deciding to ship broadly
|
||||||
|
|
||||||
## Website behavior
|
## Website behavior
|
||||||
|
|
||||||
- The website download page points to GitHub's latest published **stable** release.
|
- The website download page points to GitHub's latest published **stable** release.
|
||||||
- Published RC prereleases are public on GitHub Releases, but they do **not** become the website download target.
|
- Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target.
|
||||||
- The website only moves when you publish the final stable release tag like `v0.1.41`.
|
- The website only moves when you publish the final stable release tag like `v0.1.41`.
|
||||||
|
|
||||||
## Fixing a failed release build
|
## Fixing a failed release build
|
||||||
@@ -88,13 +89,13 @@ git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.
|
|||||||
# Android APK
|
# Android APK
|
||||||
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
||||||
|
|
||||||
# RC
|
# Beta
|
||||||
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force
|
git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
|
||||||
```
|
```
|
||||||
|
|
||||||
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
||||||
|
|
||||||
- `vX.Y.Z` or `vX.Y.Z-rc.N` rebuilds the full tagged release
|
- `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release
|
||||||
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
|
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
|
||||||
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
|
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
|
||||||
- `android-vX.Y.Z` rebuilds the Android APK release only
|
- `android-vX.Y.Z` rebuilds the Android APK release only
|
||||||
@@ -105,24 +106,26 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
|
|||||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||||
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||||
- The website uses GitHub's latest published release API for download links, so published RC prereleases do not replace the stable download target.
|
- The website uses GitHub's latest published release API for download links, so published beta prereleases do not replace the stable download target.
|
||||||
|
|
||||||
## Changelog format
|
## Changelog format
|
||||||
|
|
||||||
Stable release notes depend on the changelog heading format. The heading **must** be strictly followed:
|
Release notes depend on the changelog heading format. The heading **must** be strictly followed:
|
||||||
|
|
||||||
```
|
```
|
||||||
## X.Y.Z - YYYY-MM-DD
|
## X.Y.Z - YYYY-MM-DD
|
||||||
|
## X.Y.Z-beta.N - YYYY-MM-DD
|
||||||
```
|
```
|
||||||
|
|
||||||
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
|
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
|
||||||
|
|
||||||
## Changelog policy
|
## Changelog policy
|
||||||
|
|
||||||
- `CHANGELOG.md` is for **final stable releases only**.
|
- `CHANGELOG.md` includes stable releases and the current beta line.
|
||||||
- Do not add or edit changelog entries while iterating on RCs.
|
- The first beta inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
|
||||||
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle.
|
- The next beta updates that same top entry in place, for example from `0.1.60-beta.1` to `0.1.60-beta.2`.
|
||||||
- Between stable releases, keep changelog work out of the repo until the final release is ready.
|
- Stable promotion updates that same entry in place, for example from `0.1.60-beta.2` to `0.1.60`.
|
||||||
|
- Do not create duplicate entries for each beta on the same version line.
|
||||||
|
|
||||||
## Changelog ownership
|
## Changelog ownership
|
||||||
|
|
||||||
@@ -178,7 +181,7 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
|
|||||||
|
|
||||||
## Pre-release sanity check
|
## Pre-release sanity check
|
||||||
|
|
||||||
Before cutting any release (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
Before cutting any release (beta or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
||||||
|
|
||||||
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
|
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
|
||||||
|
|
||||||
@@ -196,10 +199,10 @@ The agent's job is a deep sanity check, not a full code review. If it flags anyt
|
|||||||
|
|
||||||
The changelog always covers **stable-to-HEAD**:
|
The changelog always covers **stable-to-HEAD**:
|
||||||
|
|
||||||
- **RC release**: the diff and release notes cover `latest stable tag → HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`.
|
- **Beta release**: the diff and release notes cover `latest stable tag -> HEAD`. The current beta changelog entry is updated in place.
|
||||||
- **Stable release**: the diff and changelog entry cover `latest stable tag → HEAD`. Any intermediate RCs are skipped — the changelog captures the full delta from the previous stable release, not just what changed since the last RC.
|
- **Stable release**: the same changelog entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
|
||||||
|
|
||||||
In other words, RCs are checkpoints along the way; the changelog only records the final jump from one stable version to the next.
|
In other words, betas are checkpoints along the way; the changelog entry remains the single record for the final jump from one stable version to the next.
|
||||||
|
|
||||||
## Completion checklist
|
## Completion checklist
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# Unistyles Gotchas
|
||||||
|
|
||||||
|
This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-aware styles. Unistyles is fast because most style updates do not go through React renders: the [Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites React Native component imports, attaches style metadata, and lets the native ShadowRegistry update tracked views when theme or runtime dependencies change.
|
||||||
|
|
||||||
|
That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles.
|
||||||
|
|
||||||
|
## How Updates Propagate
|
||||||
|
|
||||||
|
For standard React Native components, the [Unistyles Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites imports such as `View`, `Text`, `Pressable`, and `ScrollView` to Unistyles-aware component factories. On native, those factories borrow the component ref and register the `style` prop with the ShadowRegistry. The upstream ["Why my view doesn't update?"](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update) guide describes this as the ShadowTree update path that avoids unnecessary React re-renders.
|
||||||
|
|
||||||
|
The important detail: the automatic native path tracks `props.style`. It does not generally track every prop that happens to carry style-like values.
|
||||||
|
|
||||||
|
[`useUnistyles()`](https://www.unistyl.es/v3/references/use-unistyles) is different. It gives React access to the current theme/runtime and can make a component re-render when those values change. Use it for values that must be rendered through React props, such as icon colors or small escape hatches. Do not expect direct reads from `UnistylesRuntime` to re-render a component; [issue #817](https://github.com/jpudysz/react-native-unistyles/issues/817) is a useful reminder of that invariant.
|
||||||
|
|
||||||
|
## Main Gotcha: `contentContainerStyle`
|
||||||
|
|
||||||
|
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
|
||||||
|
|
||||||
|
Avoid this pattern when the style depends on the theme:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ScrollView contentContainerStyle={styles.container} />
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
flexGrow: 1,
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
On first mount this can paint with the current adaptive or initial theme. If app settings later load a persisted theme and call [`UnistylesRuntime.setTheme`](https://www.unistyl.es/v3/guides/theming#change-theme), the JS-side style proxy may report the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons.
|
||||||
|
|
||||||
|
This applies broadly to non-`style` props that carry theme-dependent values, such as component props named `color`, `trackColor`, `tintColor`, `backgroundStyle`, `handleIndicatorStyle`, and other library-specific style props. The [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views) recommends explicit handling for these cases, and [issue #1030](https://github.com/jpudysz/react-native-unistyles/issues/1030) shows a related native-prop update edge case around `Image.tintColor`. Treat these values as React props unless wrapped with `withUnistyles`.
|
||||||
|
|
||||||
|
## Fix Patterns
|
||||||
|
|
||||||
|
Preferred pattern: put themed backgrounds on a normal wrapper view, and keep `contentContainerStyle` theme-free.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ScrollView contentContainerStyle={styles.contentContainer}>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
flexGrow: 1,
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the pattern used by the settings screen: the screen background lives on a normal `View style={styles.container}`, while the scroll content container only carries layout.
|
||||||
|
|
||||||
|
When the content container itself needs themed behavior, wrap the component with [`withUnistyles`](https://www.unistyl.es/v3/references/with-unistyles):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ScrollView } from "react-native";
|
||||||
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
|
||||||
|
const ThemedScrollView = withUnistyles(ScrollView);
|
||||||
|
|
||||||
|
<ThemedScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.contentContainer}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
`withUnistyles` extracts dependency metadata from both `style` and `contentContainerStyle`, subscribes to the relevant theme/runtime changes, and re-renders only that wrapped component when needed. Its [auto-mapping behavior for `style` and `contentContainerStyle`](https://www.unistyl.es/v3/references/with-unistyles#auto-mapping-for-style-and-contentcontainerstyle-props) is the reason it fixes themed `ScrollView` content containers. Reach for it when wrapper-view layout would be awkward or when a third-party component needs theme-aware non-`style` props mapped through Unistyles.
|
||||||
|
|
||||||
|
The smallest escape hatch is to use `useUnistyles()` and pass an inline value through React:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.contentContainer,
|
||||||
|
{ backgroundColor: theme.colors.surface0 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this sparingly. It works because React re-renders the prop, but it gives up the main Unistyles native-update path for that value.
|
||||||
|
|
||||||
|
## `withUnistyles` And The `> *` Child-Selector Leak
|
||||||
|
|
||||||
|
`withUnistyles` on a component with a theme-dependent `style` prop works by wrapping the component in a `<div style={{display: 'contents'}} className={hash}>` and emitting the style under a `.hash > *` child selector so the styles cascade onto the wrapped component. This is how auto-mapping for `style` and `contentContainerStyle` works on web.
|
||||||
|
|
||||||
|
The sharp edge: Unistyles hashes styles by value. If `withUnistyles` receives a style whose value is **identical** to a style used elsewhere in the app on a plain `View`, both usages get the same hash — and both CSS rules (the element rule and the `> *` child rule) are emitted under the same class name. The `> *` rule then leaks onto the direct children of every `View` that shares the hash.
|
||||||
|
|
||||||
|
Concrete regression we hit: `welcome-screen.tsx` had `const ThemedScrollView = withUnistyles(ScrollView)` with `style={{ flex: 1, backgroundColor: theme.colors.surface0 }}`. `agent-panel.tsx` had `root` and `container` styles with the exact same value. All three collided on class `unistyles_j2k2iilhfz`, so the browser stylesheet contained:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.unistyles_j2k2iilhfz { flex: 1 1 0%; background-color: var(--colors-surface0); }
|
||||||
|
.unistyles_j2k2iilhfz > * { flex: 1 1 0%; background-color: var(--colors-surface0); }
|
||||||
|
```
|
||||||
|
|
||||||
|
The child-selector rule forced `flex:1` and `background-color: surface0` onto the Composer's outer `Animated.View` (a direct child of `container`), stretching it to fill remaining space and leaving a large empty gap between the composer UI and the bottom of the screen. It also painted a `surface0` band behind the scroll-to-bottom button. The bug only appeared in the browser — Electron skips `WelcomeScreen` after pairing, so the `> *` rule was never injected there.
|
||||||
|
|
||||||
|
Symptoms to watch for:
|
||||||
|
|
||||||
|
- A sibling of a themed panel-background `View` stretches unexpectedly on web only.
|
||||||
|
- Random direct children of a `{ flex: 1, backgroundColor: surface0 }` `View` pick up an unexpected background.
|
||||||
|
- DevTools shows a `.unistyles_xxx > *` rule you did not write.
|
||||||
|
|
||||||
|
Quick confirmation in DevTools console:
|
||||||
|
|
||||||
|
```js
|
||||||
|
[...document.styleSheets].flatMap(s => [...(s.cssRules || [])])
|
||||||
|
.map(r => r.cssText).filter(t => t.includes("unistyles") && t.includes("> *"));
|
||||||
|
```
|
||||||
|
|
||||||
|
Any match beyond benign `r-pointerEvents-* > *` rules from react-native-web is a leak.
|
||||||
|
|
||||||
|
Avoid the bug by preferring the wrapper-`View` pattern from the previous section whenever possible: put `{ flex: 1, backgroundColor: surface0 }` on a plain `View` and give the `ScrollView` a theme-free `style`/`contentContainerStyle`. That keeps `withUnistyles` off the hot path and avoids the hash collision. Only reach for `withUnistyles(ScrollView)` when a wrapper view is genuinely awkward, and when you do, give the wrapped style a distinctive shape (extra key, different layout) so it does not hash-collide with a common panel background used elsewhere.
|
||||||
|
|
||||||
|
## Hidden Sheet Content
|
||||||
|
|
||||||
|
`@gorhom/bottom-sheet` can keep `BottomSheetModal` content mounted while the sheet is hidden. That matters during Paseo's startup theme transition: a header node can be created under the initial adaptive theme, stay hidden, then appear later with stale native style values even though surrounding content has re-rendered correctly.
|
||||||
|
|
||||||
|
We saw this in `AdaptiveModalSheet`: the body text and buttons were dark-theme-correct, but the shared sheet title opened with the initial light-theme text color on a dark sheet background. For tiny values in a reusable sheet header, prefer the inline escape hatch:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
<Text style={[styles.title, { color: theme.colors.foreground }]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep layout and typography in `StyleSheet.create`; move only the stale theme-dependent value through React. If a larger subtree shows the same behavior, consider remounting the sheet on theme changes or moving the themed paint onto a wrapper that is mounted with the visible content.
|
||||||
|
|
||||||
|
The same rule applies to bottom-sheet component props such as `backgroundStyle` and `handleIndicatorStyle`: they are library props, not the direct React Native `style` prop Unistyles registers. Prefer a custom `backgroundComponent` that calls `useUnistyles()`, or pass a small inline object from the hook theme.
|
||||||
|
|
||||||
|
## Memoized Style Objects
|
||||||
|
|
||||||
|
When a third-party library receives a plain style object, it is outside Unistyles' native tracking path. Make sure any memo that builds that style object depends on the actual theme values it reads.
|
||||||
|
|
||||||
|
Avoid indirect keys like this:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { theme, rt } = useUnistyles();
|
||||||
|
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);
|
||||||
|
```
|
||||||
|
|
||||||
|
On adaptive system-theme changes, the hook can provide a light/dark theme update while an indirect runtime key is not the value that invalidates the memo. That leaves the library rendering stale colors. Assistant markdown hit this exact failure: the workspace shell switched to light, but assistant text and code spans kept the old dark-theme markdown style object.
|
||||||
|
|
||||||
|
Prefer the hook theme itself, or explicit theme tokens, as the dependency:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||||
|
```
|
||||||
|
|
||||||
|
If a style factory is cheap, skipping `useMemo` entirely is also fine.
|
||||||
|
|
||||||
|
## Static Theme Imports
|
||||||
|
|
||||||
|
Do not import `theme` from `@/styles/theme` for live UI colors. That export is a dark-theme compatibility default, so using it in render code leaves icons, placeholders, or third-party props pinned to dark colors in light mode.
|
||||||
|
|
||||||
|
Use `useUnistyles()` inside the component instead:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only.
|
||||||
|
|
||||||
|
## Adaptive Themes And Persisted Settings
|
||||||
|
|
||||||
|
Unistyles [`initialTheme`](https://www.unistyl.es/v3/guides/theming#select-theme) and [`adaptiveThemes`](https://www.unistyl.es/v3/guides/theming#adaptive-themes) are mutually exclusive. `initialTheme` can be a string or a synchronous function, but it cannot wait on async storage.
|
||||||
|
|
||||||
|
Paseo currently stores app settings in AsyncStorage and loads them through react-query. That means the app can mount under adaptive/system theme first, then switch after settings load:
|
||||||
|
|
||||||
|
1. Unistyles config starts with `adaptiveThemes: true`.
|
||||||
|
2. The device may report system light.
|
||||||
|
3. Settings load a persisted non-auto preference, such as dark.
|
||||||
|
4. The app calls `setAdaptiveThemes(false)` and `setTheme("dark")`.
|
||||||
|
|
||||||
|
That brief transition is expected with the current storage model. It makes tracking-compatible styles important: anything mounted during the initial adaptive theme must update correctly after the persisted preference applies. [Issue #550](https://github.com/jpudysz/react-native-unistyles/issues/550) was a separate ScrollView sticky-header bug, but it is still useful context for why ScrollView theme updates deserve extra suspicion.
|
||||||
|
|
||||||
|
If we ever need to avoid the transition entirely, store at least the theme preference in synchronous storage and configure Unistyles with `initialTheme`.
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
To inspect what the Babel plugin sees, temporarily enable [`debug: true`](https://www.unistyl.es/v3/other/babel-plugin#debug) in `packages/app/babel.config.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
[
|
||||||
|
"react-native-unistyles/plugin",
|
||||||
|
{
|
||||||
|
root: "src",
|
||||||
|
debug: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
Then rebuild the bundle and look for lines such as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/components/welcome-screen.tsx: styles.container: [Theme]
|
||||||
|
```
|
||||||
|
|
||||||
|
This only confirms that the stylesheet dependency was detected. The upstream debugging guide makes the same distinction: dependency detection is only one failure mode. It does not prove the style prop is registered on the native view you care about.
|
||||||
|
|
||||||
|
For paint-layer bugs, use high-contrast probes:
|
||||||
|
|
||||||
|
1. Paint each candidate layer a distinct color, such as root wrapper cyan, `ScrollView.style` yellow, and `contentContainerStyle` magenta.
|
||||||
|
2. Cold-restart the app, not just Fast Refresh.
|
||||||
|
3. Screenshot the simulator and sample pixels to see which color fills the area.
|
||||||
|
4. Remove the probes before committing.
|
||||||
|
|
||||||
|
The welcome-screen investigation used this approach to prove the white layer was the `ScrollView` content container. Deep-dive evidence is in [welcome-theme-split-research.md](/Users/moboudra/.paseo/notes/welcome-theme-split-research.md).
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Unistyles v3 documentation](https://www.unistyl.es/)
|
||||||
|
- [Theming: initial theme, adaptive themes, and runtime theme changes](https://www.unistyl.es/v3/guides/theming)
|
||||||
|
- [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue)
|
||||||
|
- [withUnistyles reference](https://www.unistyl.es/v3/references/with-unistyles)
|
||||||
|
- [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views)
|
||||||
|
- [Babel plugin debug option](https://www.unistyl.es/v3/other/babel-plugin#debug)
|
||||||
|
- [Why my view doesn't update?](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update)
|
||||||
|
- [GitHub issue #550: ScrollView sticky-header theme updates](https://github.com/jpudysz/react-native-unistyles/issues/550)
|
||||||
|
- [GitHub issue #817: `UnistylesRuntime.themeName` does not re-render](https://github.com/jpudysz/react-native-unistyles/issues/817)
|
||||||
|
- [GitHub issue #1030: `Image.tintColor` and native style update edge case](https://github.com/jpudysz/react-native-unistyles/issues/1030)
|
||||||
|
- [Local research note: welcome theme split](</Users/moboudra/.paseo/notes/welcome-theme-split-research.md>)
|
||||||
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
|||||||
|
|
||||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||||
npmDepsHash = "sha256-t3QD52Yfi2VEQxspGJMtSINFnn2Hp1wAzu+SYDIIY4Y=";
|
npmDepsHash = "sha256-6v597rirYsPQJYXvVpd0+MZfbY0I6Oqlmd/7z5eHiOw=";
|
||||||
|
|
||||||
# Prevent onnxruntime-node's install script from running during automatic
|
# 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).
|
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||||
|
|||||||
Generated
+2883
-182
File diff suppressed because it is too large
Load Diff
+18
-14
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.58",
|
"version": "0.1.60-beta.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/expo-two-way-audio",
|
"packages/expo-two-way-audio",
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||||
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
|
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
|
||||||
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
|
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
|
||||||
|
"db:query": "npm run db:query --workspace=@getpaseo/server --",
|
||||||
"cli": "npx tsx packages/cli/src/index.js",
|
"cli": "npx tsx packages/cli/src/index.js",
|
||||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||||
@@ -46,19 +47,19 @@
|
|||||||
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
|
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
|
||||||
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
|
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
|
||||||
"version:all:major": "node scripts/set-release-version.mjs --mode major",
|
"version:all:major": "node scripts/set-release-version.mjs --mode major",
|
||||||
"version:all:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch",
|
"version:all:beta:patch": "node scripts/set-release-version.mjs --mode beta-patch",
|
||||||
"version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor",
|
"version:all:beta:minor": "node scripts/set-release-version.mjs --mode beta-minor",
|
||||||
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major",
|
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
|
||||||
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next",
|
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
|
||||||
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
|
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
|
||||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||||
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||||
"release:push": "node scripts/push-current-release-tag.mjs",
|
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||||
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push",
|
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:push",
|
||||||
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push",
|
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:push",
|
||||||
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push",
|
"release:beta:major": "npm run release:check && npm run version:all:beta:major && npm run release:push",
|
||||||
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push",
|
"release:beta:next": "npm run release:check && npm run version:all:beta:next && npm run release:push",
|
||||||
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
|
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
|
||||||
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
||||||
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
||||||
@@ -70,8 +71,8 @@
|
|||||||
"get-port-cli": "^3.0.0",
|
"get-port-cli": "^3.0.0",
|
||||||
"knip": "^5.82.1",
|
"knip": "^5.82.1",
|
||||||
"patch-package": "^8.0.1",
|
"patch-package": "^8.0.1",
|
||||||
"react": "19.1.4",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.4",
|
"react-dom": "19.1.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||||
@@ -95,11 +96,14 @@
|
|||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"lightningcss": "1.30.1",
|
"lightningcss": "1.30.1",
|
||||||
"react": "19.1.4",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.4"
|
"react-dom": "19.1.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
"@modelcontextprotocol/sdk": "^1.27.1"
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
|
"expo": "~54.0.33",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-native": "0.81.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
createIdleAgent,
|
createIdleAgent,
|
||||||
expectSessionRowVisible,
|
expectSessionRowVisible,
|
||||||
expectWorkspaceArchiveOutcome,
|
expectWorkspaceArchiveOutcome,
|
||||||
|
expectWorkspaceTabHidden,
|
||||||
openSessions,
|
openSessions,
|
||||||
openWorkspaceWithAgents,
|
openWorkspaceWithAgents,
|
||||||
primeAdditionalPage,
|
primeAdditionalPage,
|
||||||
@@ -19,7 +20,7 @@ test.describe("Archive tab reconciliation", () => {
|
|||||||
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
|
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
|
||||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||||
|
|
||||||
test.describe.configure({ timeout: 120_000 });
|
test.describe.configure({ timeout: 300_000 });
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
tempRepo = await createTempGitRepo("archive-tab-");
|
tempRepo = await createTempGitRepo("archive-tab-");
|
||||||
@@ -64,10 +65,7 @@ test.describe("Archive tab reconciliation", () => {
|
|||||||
survivingAgentId: surviving.id,
|
survivingAgentId: surviving.id,
|
||||||
});
|
});
|
||||||
await reloadWorkspace(passivePage, tempRepo.path);
|
await reloadWorkspace(passivePage, tempRepo.path);
|
||||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
await expectWorkspaceTabHidden(passivePage, archived.id);
|
||||||
archivedAgentId: archived.id,
|
|
||||||
survivingAgentId: surviving.id,
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
await passivePage.close();
|
await passivePage.close();
|
||||||
}
|
}
|
||||||
@@ -93,10 +91,7 @@ test.describe("Archive tab reconciliation", () => {
|
|||||||
await openSessions(page);
|
await openSessions(page);
|
||||||
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
|
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
|
||||||
await reloadWorkspace(page, tempRepo.path);
|
await reloadWorkspace(page, tempRepo.path);
|
||||||
await expectWorkspaceArchiveOutcome(page, {
|
await expectWorkspaceTabHidden(page, archived.id);
|
||||||
archivedAgentId: archived.id,
|
|
||||||
survivingAgentId: surviving.id,
|
|
||||||
});
|
|
||||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||||
archivedAgentId: archived.id,
|
archivedAgentId: archived.id,
|
||||||
survivingAgentId: surviving.id,
|
survivingAgentId: surviving.id,
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { test } from "./fixtures";
|
||||||
|
import {
|
||||||
|
collapseFolder,
|
||||||
|
expandFolder,
|
||||||
|
expectExplorerEntryHidden,
|
||||||
|
expectExplorerEntryVisible,
|
||||||
|
expectFileTabOpen,
|
||||||
|
openFileExplorer,
|
||||||
|
openFileFromExplorer,
|
||||||
|
} from "./helpers/file-explorer";
|
||||||
|
import { gotoWorkspace } from "./helpers/launcher";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import {
|
||||||
|
connectWorkspaceSetupClient,
|
||||||
|
type WorkspaceSetupDaemonClient,
|
||||||
|
} from "./helpers/workspace-setup";
|
||||||
|
|
||||||
|
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||||
|
let workspaceId: string;
|
||||||
|
let seedClient: WorkspaceSetupDaemonClient;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
tempRepo = await createTempGitRepo("file-explorer-collapse-", {
|
||||||
|
files: [
|
||||||
|
{ path: "assets/logo.png", content: "image bytes for explorer e2e\n" },
|
||||||
|
{ path: "docs/guide.md", content: "# Guide\n" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
seedClient = await connectWorkspaceSetupClient();
|
||||||
|
const result = await seedClient.openProject(tempRepo.path);
|
||||||
|
if (!result.workspace) {
|
||||||
|
throw new Error(result.error ?? "Failed to seed workspace");
|
||||||
|
}
|
||||||
|
workspaceId = String(result.workspace.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await seedClient?.close();
|
||||||
|
await tempRepo?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("File explorer collapse", () => {
|
||||||
|
test("collapses an opened image file parent folder and still expands other folders", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
await openFileExplorer(page);
|
||||||
|
|
||||||
|
await expandFolder(page, "assets");
|
||||||
|
await expectExplorerEntryVisible(page, "logo.png");
|
||||||
|
|
||||||
|
await openFileFromExplorer(page, "logo.png");
|
||||||
|
await expectFileTabOpen(page, "assets/logo.png");
|
||||||
|
|
||||||
|
await collapseFolder(page, "assets");
|
||||||
|
await expectExplorerEntryHidden(page, "logo.png");
|
||||||
|
|
||||||
|
await expandFolder(page, "docs");
|
||||||
|
await expectExplorerEntryVisible(page, "guide.md");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
|
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { mkdtemp, rm } from "node:fs/promises";
|
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
@@ -182,6 +182,7 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
|
|||||||
let daemonProcess: ChildProcess | null = null;
|
let daemonProcess: ChildProcess | null = null;
|
||||||
let metroProcess: ChildProcess | null = null;
|
let metroProcess: ChildProcess | null = null;
|
||||||
let paseoHome: string | null = null;
|
let paseoHome: string | null = null;
|
||||||
|
let fakeGhBinDir: string | null = null;
|
||||||
let relayProcess: ChildProcess | null = null;
|
let relayProcess: ChildProcess | null = null;
|
||||||
|
|
||||||
type OfferPayload = {
|
type OfferPayload = {
|
||||||
@@ -191,6 +192,47 @@ type OfferPayload = {
|
|||||||
relay: { endpoint: string };
|
relay: { endpoint: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function createFakeGhBin(): Promise<string> {
|
||||||
|
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-"));
|
||||||
|
const ghPath = path.join(binDir, "gh");
|
||||||
|
await writeFile(
|
||||||
|
ghPath,
|
||||||
|
`#!/usr/bin/env node
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (args[0] === "auth" && args[1] === "status") {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args[0] === "pr" && args[1] === "list") {
|
||||||
|
console.log(JSON.stringify([
|
||||||
|
{
|
||||||
|
number: 515,
|
||||||
|
title: "Review selected start ref",
|
||||||
|
url: "https://github.com/getpaseo/paseo/pull/515",
|
||||||
|
state: "OPEN",
|
||||||
|
body: "Fixture pull request for app e2e.",
|
||||||
|
labels: [],
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "feature/start-from-pr"
|
||||||
|
}
|
||||||
|
]));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args[0] === "issue" && args[1] === "list") {
|
||||||
|
console.log("[]");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("Unsupported fake gh invocation: " + args.join(" "));
|
||||||
|
process.exit(1);
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
await chmod(ghPath, 0o755);
|
||||||
|
return binDir;
|
||||||
|
}
|
||||||
|
|
||||||
function stripAnsi(input: string): string {
|
function stripAnsi(input: string): string {
|
||||||
return input.replace(/\u001b\[[0-9;]*m/g, "");
|
return input.replace(/\u001b\[[0-9;]*m/g, "");
|
||||||
}
|
}
|
||||||
@@ -281,6 +323,7 @@ export default async function globalSetup() {
|
|||||||
let relayPort = 0;
|
let relayPort = 0;
|
||||||
const metroPort = await getAvailablePort();
|
const metroPort = await getAvailablePort();
|
||||||
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
|
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
|
||||||
|
fakeGhBinDir = await createFakeGhBin();
|
||||||
let relayLineBuffer = createLineBuffer();
|
let relayLineBuffer = createLineBuffer();
|
||||||
const metroLineBuffer = createLineBuffer();
|
const metroLineBuffer = createLineBuffer();
|
||||||
const daemonLineBuffer = createLineBuffer();
|
const daemonLineBuffer = createLineBuffer();
|
||||||
@@ -298,6 +341,10 @@ export default async function globalSetup() {
|
|||||||
await rm(paseoHome, { recursive: true, force: true });
|
await rm(paseoHome, { recursive: true, force: true });
|
||||||
paseoHome = null;
|
paseoHome = null;
|
||||||
}
|
}
|
||||||
|
if (fakeGhBinDir) {
|
||||||
|
await rm(fakeGhBinDir, { recursive: true, force: true });
|
||||||
|
fakeGhBinDir = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
|
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
|
||||||
@@ -482,6 +529,7 @@ export default async function globalSetup() {
|
|||||||
cwd: serverDir,
|
cwd: serverDir,
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
PATH: `${fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||||
PASEO_HOME: paseoHome,
|
PASEO_HOME: paseoHome,
|
||||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||||
|
|||||||
@@ -200,16 +200,11 @@ export const gotoHome = async (page: Page) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const openSettings = async (page: Page) => {
|
export const openSettings = async (page: Page) => {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate through the real app control so route changes stay aligned with UI behavior.
|
// Navigate through the real app control so route changes stay aligned with UI behavior.
|
||||||
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||||
await expect(settingsButton).toBeVisible();
|
await expect(settingsButton).toBeVisible();
|
||||||
await settingsButton.click();
|
await settingsButton.click();
|
||||||
await expect(page).toHaveURL(new RegExp(`/h/${escapeRegex(serverId)}/settings$`));
|
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setWorkingDirectory = async (page: Page, directory: string) => {
|
export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||||
|
|||||||
@@ -27,10 +27,15 @@ type ArchiveTabDaemonClient = {
|
|||||||
modeId: string;
|
modeId: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
title: string;
|
title: string;
|
||||||
initialPrompt: string;
|
initialPrompt?: string;
|
||||||
}): Promise<{ id: string }>;
|
}): Promise<{ id: string }>;
|
||||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||||
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
||||||
|
waitForAgentUpsert(
|
||||||
|
agentId: string,
|
||||||
|
predicate: (snapshot: { status: string }) => boolean,
|
||||||
|
timeout?: number,
|
||||||
|
): Promise<{ status: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getDaemonPort(): string {
|
function getDaemonPort(): string {
|
||||||
@@ -110,16 +115,17 @@ export async function createIdleAgent(
|
|||||||
const created = await client.createAgent({
|
const created = await client.createAgent({
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
model: "opencode/gpt-5-nano",
|
model: "opencode/gpt-5-nano",
|
||||||
modeId: "default",
|
modeId: "bypassPermissions",
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
title: input.title,
|
title: input.title,
|
||||||
initialPrompt: "Reply with exactly READY.",
|
|
||||||
});
|
});
|
||||||
const finished = await client.waitForFinish(created.id, 120_000);
|
const snapshot = await client.waitForAgentUpsert(
|
||||||
if (finished.status !== "idle") {
|
created.id,
|
||||||
throw new Error(
|
(agent) => agent.status === "idle",
|
||||||
`Expected agent ${created.id} to become idle, got ${finished.status}. Error: ${JSON.stringify((finished as Record<string, unknown>).error ?? "unknown")}`,
|
30_000,
|
||||||
);
|
);
|
||||||
|
if (snapshot.status !== "idle") {
|
||||||
|
throw new Error(`Expected agent ${created.id} to become idle, got ${snapshot.status}.`);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: created.id,
|
id: created.id,
|
||||||
@@ -188,19 +194,31 @@ export async function openWorkspaceWithAgents(
|
|||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
for (const agent of agents) {
|
for (const agent of agents) {
|
||||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
|
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
|
||||||
|
|
||||||
|
// The workspace layout consumes `?open=agent:xxx`, returns null during the effect,
|
||||||
|
// then replaces the URL with the clean workspace route after preparing the tab.
|
||||||
|
// On CI, Expo Router's rootNavigationState may take time to initialize,
|
||||||
|
// so we allow a generous timeout here (matching terminal-perf pattern).
|
||||||
|
await page.waitForURL(
|
||||||
|
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
);
|
||||||
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await expectWorkspaceTabVisible(page, agent.id);
|
await expectWorkspaceTabVisible(page, agent.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
|
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
|
||||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
|
await expect(
|
||||||
timeout: 30_000,
|
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).first(),
|
||||||
});
|
).toBeVisible({ timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
|
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
|
||||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0, {
|
await expect(
|
||||||
|
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }),
|
||||||
|
).toHaveCount(0, {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -254,13 +272,19 @@ export async function archiveAgentFromSessions(
|
|||||||
throw new Error(`Could not read bounding box for session row ${input.agentId}.`);
|
throw new Error(`Could not read bounding box for session row ${input.agentId}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Long-press the row. Idle agents are archived immediately (no modal).
|
||||||
|
// Running/initializing agents show a confirmation modal instead.
|
||||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||||
await page.mouse.down();
|
await page.mouse.down();
|
||||||
await page.waitForTimeout(900);
|
await page.waitForTimeout(900);
|
||||||
await page.mouse.up();
|
await page.mouse.up();
|
||||||
|
|
||||||
|
// If a confirmation modal appears (running agent), click the archive button.
|
||||||
const archiveButton = page.getByTestId("agent-action-archive").first();
|
const archiveButton = page.getByTestId("agent-action-archive").first();
|
||||||
await expect(archiveButton).toBeVisible({ timeout: 10_000 });
|
const modalVisible = await archiveButton.isVisible().catch(() => false);
|
||||||
await archiveButton.click();
|
if (modalVisible) {
|
||||||
|
await archiveButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
await expectSessionRowArchived(page, input.title);
|
await expectSessionRowArchived(page, input.title);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
function fileExplorerTree(page: Page) {
|
||||||
|
return page.getByTestId("file-explorer-tree-scroll");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileExplorerEntry(page: Page, name: string) {
|
||||||
|
return fileExplorerTree(page).getByText(name, { exact: true }).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openFileExplorer(page: Page): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: "Open explorer" }).first().click();
|
||||||
|
await page.getByTestId("explorer-tab-files").click();
|
||||||
|
await expect(fileExplorerTree(page)).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expandFolder(page: Page, folderName: string): Promise<void> {
|
||||||
|
await fileExplorerEntry(page, folderName).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collapseFolder(page: Page, folderName: string): Promise<void> {
|
||||||
|
await fileExplorerEntry(page, folderName).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openFileFromExplorer(page: Page, fileName: string): Promise<void> {
|
||||||
|
await fileExplorerEntry(page, fileName).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectExplorerEntryVisible(page: Page, name: string): Promise<void> {
|
||||||
|
await expect(fileExplorerEntry(page, name)).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectExplorerEntryHidden(page: Page, name: string): Promise<void> {
|
||||||
|
await expect(fileExplorerEntry(page, name)).toBeHidden({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectFileTabOpen(page: Page, filePath: string): Promise<void> {
|
||||||
|
await expect(page.getByTestId(`workspace-tab-file_${filePath}`).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||||
|
import { createTempGitRepo } from "./workspace";
|
||||||
|
|
||||||
|
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navigate to a workspace and wait for the tab bar to appear. */
|
||||||
|
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
|
||||||
|
const route = buildHostWorkspaceRoute(getServerId(), cwd);
|
||||||
|
await page.goto(route);
|
||||||
|
await waitForTabBar(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab bar queries ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Wait for the workspace tab bar to be visible. */
|
||||||
|
export async function waitForTabBar(page: Page): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
|
||||||
|
).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return all tab test IDs currently in the tab bar. */
|
||||||
|
export async function getTabTestIds(page: Page): Promise<string[]> {
|
||||||
|
const tabs = page
|
||||||
|
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
|
||||||
|
.filter({ visible: true });
|
||||||
|
const count = await tabs.count();
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const testId = await tabs.nth(i).getAttribute("data-testid");
|
||||||
|
if (testId) ids.push(testId);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the number of tabs matching a kind prefix (e.g. "launcher", "draft", "terminal", "agent"). */
|
||||||
|
export async function countTabsOfKind(page: Page, kind: string): Promise<number> {
|
||||||
|
const ids = await getTabTestIds(page);
|
||||||
|
return ids.filter((id) => id.includes(kind)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the currently active tab's test ID (the one with aria-selected or focus styling). */
|
||||||
|
export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
||||||
|
// Active tab has the focused highlight — check for the aria-selected or data-active attribute
|
||||||
|
const activeTab = page
|
||||||
|
.locator(
|
||||||
|
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])[aria-selected="true"]',
|
||||||
|
)
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first();
|
||||||
|
if (await activeTab.isVisible().catch(() => false)) {
|
||||||
|
return activeTab.getAttribute("data-testid");
|
||||||
|
}
|
||||||
|
// Fallback: the tab with focused styling
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab actions ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */
|
||||||
|
export async function clickNewTabButton(page: Page): Promise<void> {
|
||||||
|
const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
|
||||||
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */
|
||||||
|
export async function clickNewTerminalButton(page: Page): Promise<void> {
|
||||||
|
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
||||||
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open a new tab. */
|
||||||
|
export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||||
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||||
|
await page.keyboard.press(`${modifier}+t`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab bar assertions ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** @deprecated The launcher panel was removed. Actions go directly to their target. */
|
||||||
|
export async function waitForLauncherPanel(page: Page): Promise<void> {
|
||||||
|
// No-op: the launcher panel no longer exists.
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assert the new agent tab button is visible in the tab bar. */
|
||||||
|
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assert the new terminal button is visible in the tab bar. */
|
||||||
|
export async function assertTerminalTileVisible(page: Page): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("workspace-new-terminal").filter({ visible: true }).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab creation actions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Click the new agent tab button to create a draft/chat tab. */
|
||||||
|
export async function clickNewChat(page: Page): Promise<void> {
|
||||||
|
const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
|
||||||
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click the new terminal button to create a terminal tab. */
|
||||||
|
export async function clickTerminal(page: Page): Promise<void> {
|
||||||
|
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
||||||
|
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab title assertions ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Wait for any tab in the bar to display the given title text. */
|
||||||
|
export async function waitForTabWithTitle(
|
||||||
|
page: Page,
|
||||||
|
title: string | RegExp,
|
||||||
|
timeout = 30_000,
|
||||||
|
): Promise<void> {
|
||||||
|
const matcher = typeof title === "string" ? new RegExp(title, "i") : title;
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
|
||||||
|
.filter({ hasText: matcher })
|
||||||
|
.filter({ visible: true })
|
||||||
|
.first(),
|
||||||
|
).toBeVisible({ timeout });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assert the new agent tab button is visible in the tab bar. */
|
||||||
|
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
||||||
|
const buttons = page.getByTestId("workspace-new-agent-tab").filter({ visible: true });
|
||||||
|
const count = await buttons.count();
|
||||||
|
expect(count).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── No-flash measurement ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure the time between clicking a launcher tile and the replacement panel becoming visible.
|
||||||
|
* Returns elapsed milliseconds.
|
||||||
|
*/
|
||||||
|
export async function measureTileTransition(
|
||||||
|
page: Page,
|
||||||
|
clickAction: () => Promise<void>,
|
||||||
|
successLocator: ReturnType<Page["locator"]>,
|
||||||
|
timeout = 5_000,
|
||||||
|
): Promise<number> {
|
||||||
|
const start = Date.now();
|
||||||
|
await clickAction();
|
||||||
|
await expect(successLocator).toBeVisible({ timeout });
|
||||||
|
return Date.now() - start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sample tab IDs at high frequency across a transition to detect blank/intermediate states.
|
||||||
|
* Returns all unique snapshots observed.
|
||||||
|
*/
|
||||||
|
export async function sampleTabsDuringTransition(
|
||||||
|
page: Page,
|
||||||
|
action: () => Promise<void>,
|
||||||
|
durationMs = 2_000,
|
||||||
|
intervalMs = 30,
|
||||||
|
): Promise<string[][]> {
|
||||||
|
const snapshots: string[][] = [];
|
||||||
|
const startSampling = async () => {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < durationMs) {
|
||||||
|
snapshots.push(await getTabTestIds(page));
|
||||||
|
await page.waitForTimeout(intervalMs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const samplingPromise = startSampling();
|
||||||
|
await action();
|
||||||
|
await samplingPromise;
|
||||||
|
return snapshots;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Workspace setup ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Create a temp git repo and return its path with a cleanup function. */
|
||||||
|
export async function createWorkspace(
|
||||||
|
prefix = "launcher-e2e-",
|
||||||
|
): ReturnType<typeof createTempGitRepo> {
|
||||||
|
return createTempGitRepo(prefix);
|
||||||
|
}
|
||||||
@@ -84,21 +84,6 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
|
|||||||
return decodeWorkspaceIdFromPathSegment(match[1]);
|
return decodeWorkspaceIdFromPathSegment(match[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseWorkspaceIdFromSidebarRowTestId(
|
|
||||||
testId: string,
|
|
||||||
input: { serverId: string; previousWorkspaceId: string },
|
|
||||||
): string | null {
|
|
||||||
const prefix = `sidebar-workspace-row-${input.serverId}:`;
|
|
||||||
if (!testId.startsWith(prefix)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const workspaceId = testId.slice(prefix.length).trim();
|
|
||||||
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return workspaceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
|
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
|
||||||
const DaemonClient = await loadDaemonClientConstructor();
|
const DaemonClient = await loadDaemonClientConstructor();
|
||||||
const webSocketFactory = createNodeWebSocketFactory();
|
const webSocketFactory = createNodeWebSocketFactory();
|
||||||
@@ -168,7 +153,7 @@ export async function createWorktreeViaDaemon(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clickNewWorkspaceButton(
|
export async function openNewWorkspaceComposer(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { projectKey: string; projectDisplayName: string },
|
input: { projectKey: string; projectDisplayName: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -179,42 +164,80 @@ export async function clickNewWorkspaceButton(
|
|||||||
const button = page.getByTestId(`sidebar-project-new-worktree-${input.projectKey}`).first();
|
const button = page.getByTestId(`sidebar-project-new-worktree-${input.projectKey}`).first();
|
||||||
await expect(button).toBeVisible({ timeout: 30_000 });
|
await expect(button).toBeVisible({ timeout: 30_000 });
|
||||||
await button.click();
|
await button.click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickNewWorkspaceButton(
|
||||||
|
page: Page,
|
||||||
|
input: { projectKey: string; projectDisplayName: string },
|
||||||
|
): Promise<void> {
|
||||||
|
await openNewWorkspaceComposer(page, input);
|
||||||
|
const createButton = page
|
||||||
|
.getByTestId("message-input-root")
|
||||||
|
.getByRole("button", { name: "Create" });
|
||||||
|
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||||
|
await createButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openStartingRefPicker(page: Page): Promise<void> {
|
||||||
|
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
|
||||||
|
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||||
|
await trigger.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectBranchInPicker(page: Page, name: string): Promise<void> {
|
||||||
|
const branchRow = page.getByTestId(`new-workspace-ref-picker-branch-${name}`);
|
||||||
|
await expect(branchRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
await branchRow.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
|
||||||
|
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
|
||||||
|
await expect(prRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
await prRow.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectStartingRefPickerTriggerPr(
|
||||||
|
page: Page,
|
||||||
|
input: { number: number; title: string; headRef: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
|
||||||
|
await expect(trigger).toContainText(`#${input.number}`);
|
||||||
|
await expect(trigger).toContainText(input.title);
|
||||||
|
await expect(trigger).not.toContainText(input.headRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectComposerGithubAttachmentPill(
|
||||||
|
page: Page,
|
||||||
|
input: { number: number; title: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const pills = page.getByTestId("composer-github-attachment-pill");
|
||||||
|
await expect(pills).toHaveCount(1);
|
||||||
|
await expect(pills.first()).toContainText(`#${input.number}`);
|
||||||
|
await expect(pills.first()).toContainText(input.title);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assertNewWorkspaceSidebarAndHeader(
|
export async function assertNewWorkspaceSidebarAndHeader(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },
|
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },
|
||||||
): Promise<{ workspaceId: string }> {
|
): Promise<{ workspaceId: string }> {
|
||||||
|
// Wait for URL to redirect to the newly created workspace.
|
||||||
|
// Uses URL as source of truth to avoid picking up sidebar rows from concurrent tests.
|
||||||
let workspaceId: string | null = null;
|
let workspaceId: string | null = null;
|
||||||
const sidebarWorkspaceRows = page.locator(
|
const deadline = Date.now() + 60_000;
|
||||||
`[data-testid^="sidebar-workspace-row-${input.serverId}:"]`,
|
|
||||||
);
|
|
||||||
const deadline = Date.now() + 30_000;
|
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
const sidebarRowTestIds = await sidebarWorkspaceRows.evaluateAll((elements) =>
|
workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
|
||||||
elements.map((element) => element.getAttribute("data-testid") ?? ""),
|
if (workspaceId && workspaceId !== input.previousWorkspaceId) {
|
||||||
);
|
|
||||||
workspaceId =
|
|
||||||
sidebarRowTestIds
|
|
||||||
.map((testId) => parseWorkspaceIdFromSidebarRowTestId(testId, input))
|
|
||||||
.find((id) => id !== null) ?? null;
|
|
||||||
if (workspaceId) {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
await page.waitForTimeout(250);
|
await page.waitForTimeout(250);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
|
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
|
||||||
const sidebarWorkspaceRowIds = await sidebarWorkspaceRows.evaluateAll((elements) =>
|
throw new Error(`Expected URL to redirect to a new workspace.\nCurrent URL: ${page.url()}`);
|
||||||
elements.map((element) => element.getAttribute("data-testid") ?? "<missing-testid>"),
|
|
||||||
);
|
|
||||||
throw new Error(
|
|
||||||
[
|
|
||||||
"Expected a newly created workspace to load.",
|
|
||||||
`Current URL: ${page.url()}`,
|
|
||||||
`Sidebar rows: ${sidebarWorkspaceRowIds.join(", ") || "<none>"}`,
|
|
||||||
].join("\n"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdWorkspaceRow = page.getByTestId(
|
const createdWorkspaceRow = page.getByTestId(
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
|||||||
export type TerminalPerfDaemonClient = {
|
export type TerminalPerfDaemonClient = {
|
||||||
connect(): Promise<void>;
|
connect(): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
|
openProject(cwd: string): Promise<{
|
||||||
|
workspace: { id: string; name: string; projectRootPath: string } | null;
|
||||||
|
error: string | null;
|
||||||
|
}>;
|
||||||
createTerminal(
|
createTerminal(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
name?: string,
|
name?: string,
|
||||||
@@ -79,14 +83,14 @@ export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient>
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): string {
|
export function buildTerminalWorkspaceUrl(workspaceId: string, terminalId: string): string {
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
const route = buildHostWorkspaceRoute(serverId, cwd);
|
const route = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||||
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
|
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWorkspaceUrl(cwd: string): string {
|
function buildWorkspaceUrl(workspaceId: string): string {
|
||||||
return buildHostWorkspaceRoute(getServerId(), cwd);
|
return buildHostWorkspaceRoute(getServerId(), workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTerminalBufferText(page: Page): Promise<string> {
|
export async function getTerminalBufferText(page: Page): Promise<string> {
|
||||||
@@ -125,31 +129,35 @@ export async function waitForTerminalContent(
|
|||||||
|
|
||||||
export async function navigateToTerminal(
|
export async function navigateToTerminal(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { cwd: string; terminalId: string },
|
input: { workspaceId: string; terminalId: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Boot the app at the workspace route directly.
|
// Boot the app at the workspace route directly.
|
||||||
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
|
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
|
||||||
// so the daemon registry is already configured when the app starts.
|
// so the daemon registry is already configured when the app starts.
|
||||||
const workspaceRoute = buildTerminalWorkspaceUrl(input.cwd, input.terminalId);
|
const workspaceRoute = buildTerminalWorkspaceUrl(input.workspaceId, input.terminalId);
|
||||||
await page.goto(workspaceRoute);
|
await page.goto(workspaceRoute);
|
||||||
|
|
||||||
// The workspace layout consumes `?open=...`, returns null during the effect,
|
// The workspace layout consumes `?open=...`, returns null during the effect,
|
||||||
// then replaces the URL with the clean workspace route after preparing the tab.
|
// then replaces the URL with the clean workspace route after preparing the tab.
|
||||||
const cleanWorkspaceRoute = buildWorkspaceUrl(input.cwd);
|
// On CI, Expo Router's rootNavigationState may take time to initialize,
|
||||||
|
// so we allow a generous timeout here.
|
||||||
|
const cleanWorkspaceRoute = buildWorkspaceUrl(input.workspaceId);
|
||||||
await page.waitForURL(
|
await page.waitForURL(
|
||||||
(url) => url.pathname === cleanWorkspaceRoute && !url.searchParams.has("open"),
|
(url) => url.pathname === cleanWorkspaceRoute && !url.searchParams.has("open"),
|
||||||
{ timeout: 15_000 },
|
{ timeout: 30_000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait for daemon connection (sidebar shows host label)
|
// Wait for daemon connection (sidebar shows host label)
|
||||||
await page
|
await page
|
||||||
.getByText("localhost", { exact: true })
|
.getByText("localhost", { exact: true })
|
||||||
.first()
|
.first()
|
||||||
.waitFor({ state: "visible", timeout: 15_000 });
|
.waitFor({ state: "visible", timeout: 30_000 });
|
||||||
|
|
||||||
// The open intent should have prepared and focused the exact pre-created terminal tab.
|
// The open intent should have prepared and focused the exact pre-created terminal tab.
|
||||||
|
// The tab reconciliation effect also auto-creates terminal tabs once hydration completes,
|
||||||
|
// so we give it enough time for the full workspace hydration + tab creation cycle.
|
||||||
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal_${input.terminalId}"]`);
|
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal_${input.terminalId}"]`);
|
||||||
await terminalTab.waitFor({ state: "visible", timeout: 15_000 });
|
await terminalTab.waitFor({ state: "visible", timeout: 30_000 });
|
||||||
await terminalTab.click();
|
await terminalTab.click();
|
||||||
|
|
||||||
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
|
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { clickNewChat, clickTerminal } from "./launcher";
|
||||||
|
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
|
||||||
|
|
||||||
|
function terminalSurface(page: Page) {
|
||||||
|
return page.locator('[data-testid="terminal-surface"]').first();
|
||||||
|
}
|
||||||
|
|
||||||
|
function composerInput(page: Page) {
|
||||||
|
return page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectTerminalCwd(page: Page, expectedPath: string): Promise<void> {
|
||||||
|
const terminal = terminalSurface(page);
|
||||||
|
await expect(terminal).toBeVisible({ timeout: 20_000 });
|
||||||
|
await terminal.click();
|
||||||
|
await setupDeterministicPrompt(page, `SENTINEL_${Date.now()}`);
|
||||||
|
await terminal.pressSequentially("pwd\n", { delay: 0 });
|
||||||
|
await waitForTerminalContent(page, (text) => text.includes(expectedPath), 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStandaloneTerminalFromLauncher(page: Page): Promise<void> {
|
||||||
|
await clickTerminal(page);
|
||||||
|
await expect(terminalSurface(page)).toBeVisible({ timeout: 20_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAgentChatFromLauncher(page: Page): Promise<void> {
|
||||||
|
await clickNewChat(page);
|
||||||
|
await expect(composerInput(page)).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
|
||||||
|
await expect(page.getByTestId("agent-loading")).toHaveCount(0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import { realpathSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
|
||||||
|
import { gotoAppShell } from "./app";
|
||||||
|
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||||
|
import type { SessionOutboundMessage } from "@server/shared/messages";
|
||||||
|
|
||||||
|
type WorkspaceSetupDaemonClient = {
|
||||||
|
connect(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
openProject(cwd: string): Promise<{
|
||||||
|
workspace: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
workspaceDirectory: string;
|
||||||
|
projectRootPath: string;
|
||||||
|
} | null;
|
||||||
|
error: string | null;
|
||||||
|
}>;
|
||||||
|
createPaseoWorktree(input: { cwd: string; worktreeSlug?: string }): Promise<{
|
||||||
|
workspace: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
workspaceDirectory: string;
|
||||||
|
projectRootPath: string;
|
||||||
|
} | null;
|
||||||
|
error: string | null;
|
||||||
|
}>;
|
||||||
|
fetchWorkspaces(): Promise<{
|
||||||
|
entries: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
workspaceDirectory: string;
|
||||||
|
projectRootPath: string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
fetchAgents(): Promise<{
|
||||||
|
entries: Array<{
|
||||||
|
agent: { id: string; cwd: string; workspaceId?: string | null };
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
fetchAgent(agentId: string): Promise<{
|
||||||
|
agent: { id: string; cwd: string } | null;
|
||||||
|
project: unknown | null;
|
||||||
|
} | null>;
|
||||||
|
listTerminals(cwd: string): Promise<{
|
||||||
|
cwd?: string;
|
||||||
|
terminals: Array<{ id: string; cwd: string; name: string }>;
|
||||||
|
error?: string | null;
|
||||||
|
}>;
|
||||||
|
subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceSetupProgressPayload = Extract<
|
||||||
|
SessionOutboundMessage,
|
||||||
|
{ type: "workspace_setup_progress" }
|
||||||
|
>["payload"];
|
||||||
|
|
||||||
|
export type { WorkspaceSetupDaemonClient };
|
||||||
|
|
||||||
|
function getDaemonWsUrl(): string {
|
||||||
|
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||||
|
if (!daemonPort) {
|
||||||
|
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||||
|
}
|
||||||
|
return `ws://127.0.0.1:${daemonPort}/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDaemonClientConstructor(): Promise<
|
||||||
|
new (config: {
|
||||||
|
url: string;
|
||||||
|
clientId: string;
|
||||||
|
clientType: "cli";
|
||||||
|
webSocketFactory?: NodeWebSocketFactory;
|
||||||
|
}) => WorkspaceSetupDaemonClient
|
||||||
|
> {
|
||||||
|
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||||
|
const moduleUrl = pathToFileURL(
|
||||||
|
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||||
|
).href;
|
||||||
|
const mod = (await import(moduleUrl)) as {
|
||||||
|
DaemonClient: new (config: {
|
||||||
|
url: string;
|
||||||
|
clientId: string;
|
||||||
|
clientType: "cli";
|
||||||
|
webSocketFactory?: NodeWebSocketFactory;
|
||||||
|
}) => WorkspaceSetupDaemonClient;
|
||||||
|
};
|
||||||
|
return mod.DaemonClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
|
||||||
|
const DaemonClient = await loadDaemonClientConstructor();
|
||||||
|
const webSocketFactory = createNodeWebSocketFactory();
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: getDaemonWsUrl(),
|
||||||
|
clientId: `workspace-setup-${randomUUID()}`,
|
||||||
|
clientType: "cli",
|
||||||
|
webSocketFactory,
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedProjectForWorkspaceSetup(
|
||||||
|
client: WorkspaceSetupDaemonClient,
|
||||||
|
repoPath: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const result = await client.openProject(repoPath);
|
||||||
|
if (!result.workspace || result.error) {
|
||||||
|
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectNameFromPath(repoPath: string): string {
|
||||||
|
return repoPath.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? repoPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openHomeWithProject(page: Page, repoPath: string): Promise<void> {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.locator('[data-testid^="sidebar-project-row-"]')
|
||||||
|
.filter({ hasText: projectNameFromPath(repoPath) })
|
||||||
|
.first(),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWorkspaceButton(page: Page, repoPath: string) {
|
||||||
|
return page.getByRole("button", {
|
||||||
|
name: `Create a new workspace for ${projectNameFromPath(repoPath)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revealWorkspaceButton(page: Page, repoPath: string): Promise<void> {
|
||||||
|
await page
|
||||||
|
.locator('[data-testid^="sidebar-project-row-"]')
|
||||||
|
.filter({ hasText: projectNameFromPath(repoPath) })
|
||||||
|
.first()
|
||||||
|
.hover();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorkspaceFromSidebar(page: Page, repoPath: string): Promise<void> {
|
||||||
|
const button = createWorkspaceButton(page, repoPath);
|
||||||
|
await revealWorkspaceButton(page, repoPath);
|
||||||
|
await expect(button).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(button).toBeEnabled({ timeout: 30_000 });
|
||||||
|
await button.click();
|
||||||
|
await expect(page).toHaveURL(/\/new\?/, { timeout: 30_000 });
|
||||||
|
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCurrentWorkspaceIdFromRoute(page: Page): Promise<string> {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => parseHostWorkspaceRouteFromPathname(new URL(page.url()).pathname)?.workspaceId ?? null,
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.not.toBeNull();
|
||||||
|
|
||||||
|
const workspaceId =
|
||||||
|
parseHostWorkspaceRouteFromPathname(new URL(page.url()).pathname)?.workspaceId ?? null;
|
||||||
|
if (!workspaceId) {
|
||||||
|
throw new Error(`Expected a workspace route but found ${page.url()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspaceSetupDialog(page: Page) {
|
||||||
|
return page.getByTestId("workspace-setup-dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createChatAgentFromWorkspaceSetup(
|
||||||
|
page: Page,
|
||||||
|
input: { message: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const messageInput = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||||
|
await expect(messageInput).toBeVisible({ timeout: 15_000 });
|
||||||
|
await messageInput.fill(input.message);
|
||||||
|
await messageInput.press("Enter");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated The new workspace screen no longer has a standalone terminal button.
|
||||||
|
* Use the daemon API to create a workspace, then open a terminal from the launcher.
|
||||||
|
*/
|
||||||
|
export async function createStandaloneTerminalFromWorkspaceSetup(page: Page): Promise<void> {
|
||||||
|
await workspaceSetupDialog(page)
|
||||||
|
.getByRole("button", { name: /^Terminal Create the workspace/i })
|
||||||
|
.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForWorkspaceSetupDialogToClose(
|
||||||
|
page: Page,
|
||||||
|
timeoutMs = 45_000,
|
||||||
|
): Promise<void> {
|
||||||
|
const dialog = workspaceSetupDialog(page);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(dialog).toHaveCount(0, { timeout: timeoutMs });
|
||||||
|
} catch (error) {
|
||||||
|
const dialogText = (await dialog.textContent().catch(() => null))?.replace(/\s+/g, " ").trim();
|
||||||
|
throw new Error(
|
||||||
|
dialogText
|
||||||
|
? `Workspace setup dialog stayed open. Visible text: ${dialogText}`
|
||||||
|
: `Workspace setup dialog did not close within ${timeoutMs}ms`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSetupPanel(page: Page): Promise<void> {
|
||||||
|
// If the setup panel is already visible (auto-opened), we're done.
|
||||||
|
const panel = page.getByTestId("workspace-setup-panel");
|
||||||
|
if (await panel.isVisible().catch(() => false)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Otherwise open it manually via workspace header actions menu.
|
||||||
|
// Use the specific testID to avoid matching the sidebar kebab which shares
|
||||||
|
// the same "Workspace actions" accessibility label.
|
||||||
|
const actionsButton = page.getByTestId("workspace-header-menu-trigger");
|
||||||
|
await expect(actionsButton).toBeVisible({ timeout: 10_000 });
|
||||||
|
await actionsButton.click();
|
||||||
|
const showSetup = page.getByTestId("workspace-header-show-setup");
|
||||||
|
await expect(showSetup).toBeVisible({ timeout: 5_000 });
|
||||||
|
await showSetup.click();
|
||||||
|
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSetupStatus(
|
||||||
|
page: Page,
|
||||||
|
status: "Running" | "Completed" | "Failed",
|
||||||
|
): Promise<void> {
|
||||||
|
await expect(page.getByTestId("workspace-setup-status")).toContainText(status, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectSetupLogContains(page: Page, text: string): Promise<void> {
|
||||||
|
await expect(page.getByTestId("workspace-setup-log")).toContainText(text, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectNoSetupMessage(page: Page): Promise<void> {
|
||||||
|
await expect(
|
||||||
|
page.getByText("No setup commands ran for this workspace.", { exact: true }),
|
||||||
|
).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorkspaceThroughDaemon(
|
||||||
|
client: WorkspaceSetupDaemonClient,
|
||||||
|
input: { cwd: string; worktreeSlug: string },
|
||||||
|
): Promise<{ id: string; name: string }> {
|
||||||
|
const result = await client.createPaseoWorktree(input);
|
||||||
|
if (!result.workspace || result.error) {
|
||||||
|
throw new Error(result.error ?? `Failed to create workspace for ${input.cwd}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: String(result.workspace.id),
|
||||||
|
name: result.workspace.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findWorktreeWorkspaceForProject(
|
||||||
|
client: WorkspaceSetupDaemonClient,
|
||||||
|
repoPath: string,
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
projectRootPath: string;
|
||||||
|
workspaceDirectory: string;
|
||||||
|
}> {
|
||||||
|
const payload = await client.fetchWorkspaces();
|
||||||
|
const normalizedRepoPath = realpathSync(repoPath);
|
||||||
|
const workspace =
|
||||||
|
payload.entries.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.projectRootPath === normalizedRepoPath &&
|
||||||
|
entry.workspaceDirectory !== normalizedRepoPath,
|
||||||
|
) ?? null;
|
||||||
|
if (!workspace) {
|
||||||
|
throw new Error(`Failed to find created worktree workspace for ${repoPath}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: String(workspace.id),
|
||||||
|
name: workspace.name,
|
||||||
|
projectRootPath: workspace.projectRootPath,
|
||||||
|
workspaceDirectory: workspace.workspaceDirectory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWorkspaceById(
|
||||||
|
client: WorkspaceSetupDaemonClient,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
workspaceDirectory: string;
|
||||||
|
projectRootPath: string;
|
||||||
|
}> {
|
||||||
|
const payload = await client.fetchWorkspaces();
|
||||||
|
const workspace = payload.entries.find((entry) => String(entry.id) === workspaceId) ?? null;
|
||||||
|
if (!workspace) {
|
||||||
|
throw new Error(`Workspace not found: ${workspaceId}`);
|
||||||
|
}
|
||||||
|
return workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForWorkspaceSetupProgress(
|
||||||
|
client: WorkspaceSetupDaemonClient,
|
||||||
|
predicate: (payload: WorkspaceSetupProgressPayload) => boolean,
|
||||||
|
timeoutMs = 30_000,
|
||||||
|
): Promise<WorkspaceSetupProgressPayload> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
unsubscribe();
|
||||||
|
reject(new Error(`Timed out waiting for workspace_setup_progress after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
const unsubscribe = client.subscribeRawMessages((message) => {
|
||||||
|
if (message.type !== "workspace_setup_progress") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!predicate(message.payload)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(timeout);
|
||||||
|
unsubscribe();
|
||||||
|
resolve(message.payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,15 +13,49 @@ export async function getWorkspaceTabTestIds(page: Page): Promise<string[]> {
|
|||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function visibleTestId(page: Page, testId: string) {
|
||||||
|
return page.getByTestId(testId).filter({ visible: true });
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
||||||
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
|
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({
|
await expect(visibleTestId(page, "workspace-new-agent-tab").first()).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise<string[]> {
|
||||||
|
const tabs = page.locator('[data-testid^="workspace-tab-agent_"]').filter({ visible: true });
|
||||||
|
const count = await tabs.count();
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const testId = await tabs.nth(index).getAttribute("data-testid");
|
||||||
|
if (testId && !ids.includes(testId)) {
|
||||||
|
ids.push(testId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectOnlyWorkspaceAgentTabsVisible(
|
||||||
|
page: Page,
|
||||||
|
expectedAgentIds: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const expected = new Set(expectedAgentIds.map((id) => `workspace-tab-agent_${id}`));
|
||||||
|
const visible = await getVisibleWorkspaceAgentTabIds(page);
|
||||||
|
const unexpected = visible.filter((id) => !expected.has(id));
|
||||||
|
|
||||||
|
expect(unexpected).toEqual([]);
|
||||||
|
expect(visible.length).toBe(expected.size);
|
||||||
|
for (const expectedId of expectedAgentIds) {
|
||||||
|
await expect(visibleTestId(page, `workspace-tab-agent_${expectedId}`).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void> {
|
export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void> {
|
||||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||||
if (!(await toggle.isVisible().catch(() => false))) {
|
if (!(await toggle.isVisible().catch(() => false))) {
|
||||||
|
|||||||
@@ -6,6 +6,17 @@ export async function openNewAgentComposer(page: Page): Promise<void> {
|
|||||||
await gotoHome(page);
|
await gotoHome(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the sidebar to show at least one project row, indicating that the
|
||||||
|
* WebSocket connection is up and workspace hydration has completed.
|
||||||
|
*/
|
||||||
|
export async function waitForSidebarHydration(page: Page, timeout = 60_000): Promise<void> {
|
||||||
|
await page
|
||||||
|
.locator('[data-testid^="sidebar-project-row-"]')
|
||||||
|
.first()
|
||||||
|
.waitFor({ state: "visible", timeout });
|
||||||
|
}
|
||||||
|
|
||||||
export function workspaceLabelFromPath(value: string): string {
|
export function workspaceLabelFromPath(value: string): string {
|
||||||
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
|
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
const parts = normalized.split("/").filter(Boolean);
|
const parts = normalized.split("/").filter(Boolean);
|
||||||
@@ -35,6 +46,29 @@ function workspaceRowLocator(page: Page, serverId: string, workspacePath: string
|
|||||||
return page.locator(ids.join(",")).first();
|
return page.locator(ids.join(",")).first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function expectSidebarWorkspaceSelected(input: {
|
||||||
|
page: Page;
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
selected?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const row = workspaceRowLocator(input.page, input.serverId, input.workspaceId);
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
const expected = input.selected === false ? "false" : "true";
|
||||||
|
|
||||||
|
const hasDataSelected = await row.getAttribute("data-selected");
|
||||||
|
if (hasDataSelected !== null) {
|
||||||
|
await expect(row).toHaveAttribute("data-selected", expected, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(row).toHaveAttribute("aria-selected", expected, {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function switchWorkspaceViaSidebar(input: {
|
export async function switchWorkspaceViaSidebar(input: {
|
||||||
page: Page;
|
page: Page;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -50,12 +84,27 @@ export async function switchWorkspaceViaSidebar(input: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a workspace's sidebar row to appear, confirming the workspace
|
||||||
|
* descriptor has been hydrated into the session store.
|
||||||
|
*/
|
||||||
|
export async function waitForWorkspaceInSidebar(
|
||||||
|
page: Page,
|
||||||
|
input: { serverId: string; workspaceId: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const candidates = candidateWorkspaceIds(input.workspaceId);
|
||||||
|
const selector = candidates
|
||||||
|
.map((id) => `[data-testid="sidebar-workspace-row-${input.serverId}:${id}"]`)
|
||||||
|
.join(",");
|
||||||
|
await page.locator(selector).first().waitFor({ state: "visible", timeout: 60_000 });
|
||||||
|
}
|
||||||
|
|
||||||
export async function expectWorkspaceHeader(
|
export async function expectWorkspaceHeader(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { title: string; subtitle: string },
|
input: { title: string; subtitle: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const titleLocator = page.getByTestId("workspace-header-title");
|
const titleLocator = page.getByTestId("workspace-header-title").filter({ visible: true });
|
||||||
const subtitleLocator = page.getByTestId("workspace-header-subtitle");
|
const subtitleLocator = page.getByTestId("workspace-header-subtitle").filter({ visible: true });
|
||||||
|
|
||||||
await expect(titleLocator.first()).toHaveText(input.title, {
|
await expect(titleLocator.first()).toHaveText(input.title, {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
|
import { mkdtemp, writeFile, rm, mkdir, realpath } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
type TempRepo = {
|
type TempRepo = {
|
||||||
path: string;
|
path: string;
|
||||||
|
branchHeads: Record<string, string>;
|
||||||
cleanup: () => Promise<void>;
|
cleanup: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createTempGitRepo = async (
|
export const createTempGitRepo = async (
|
||||||
prefix = "paseo-e2e-",
|
prefix = "paseo-e2e-",
|
||||||
options?: { withRemote?: boolean },
|
options?: {
|
||||||
|
withRemote?: boolean;
|
||||||
|
paseoConfig?: Record<string, unknown>;
|
||||||
|
files?: Array<{ path: string; content: string }>;
|
||||||
|
branches?: string[];
|
||||||
|
},
|
||||||
): Promise<TempRepo> => {
|
): Promise<TempRepo> => {
|
||||||
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
||||||
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
|
// Resolve symlinks (macOS: /tmp → /private/tmp) so paths match the daemon's resolved paths.
|
||||||
|
const tempRoot = process.platform === "win32" ? tmpdir() : await realpath("/tmp");
|
||||||
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
|
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
|
||||||
const withRemote = options?.withRemote ?? false;
|
const withRemote = options?.withRemote ?? false;
|
||||||
|
|
||||||
@@ -22,22 +29,92 @@ export const createTempGitRepo = async (
|
|||||||
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
|
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
|
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
|
||||||
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
|
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
|
||||||
|
if (options?.paseoConfig) {
|
||||||
|
await writeFile(
|
||||||
|
path.join(repoPath, "paseo.json"),
|
||||||
|
JSON.stringify(options.paseoConfig, null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const file of options?.files ?? []) {
|
||||||
|
const filePath = path.join(repoPath, file.path);
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await writeFile(filePath, file.content);
|
||||||
|
}
|
||||||
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
|
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
|
||||||
|
if (options?.paseoConfig) {
|
||||||
|
execSync("git add paseo.json", { cwd: repoPath, stdio: "ignore" });
|
||||||
|
}
|
||||||
|
for (const file of options?.files ?? []) {
|
||||||
|
execSync(`git add ${JSON.stringify(file.path)}`, { cwd: repoPath, stdio: "ignore" });
|
||||||
|
}
|
||||||
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
|
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
|
||||||
|
|
||||||
|
const branchHeads: Record<string, string> = {};
|
||||||
|
const branches = Array.from(new Set(options?.branches ?? []));
|
||||||
|
for (const branch of branches) {
|
||||||
|
if (branch !== "main") {
|
||||||
|
execSync(`git checkout -b ${JSON.stringify(branch)} main`, {
|
||||||
|
cwd: repoPath,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const markerPath = `.paseo-e2e-${branch.replace(/[^a-zA-Z0-9._-]/g, "-")}.txt`;
|
||||||
|
await writeFile(path.join(repoPath, markerPath), `branch ${branch}\n`);
|
||||||
|
execSync(`git add ${JSON.stringify(markerPath)}`, { cwd: repoPath, stdio: "ignore" });
|
||||||
|
execSync(`git commit -m ${JSON.stringify(`Add ${branch} marker`)}`, {
|
||||||
|
cwd: repoPath,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
branchHeads[branch] = execSync(`git rev-parse ${JSON.stringify(branch)}`, {
|
||||||
|
cwd: repoPath,
|
||||||
|
stdio: "pipe",
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
execSync("git checkout main", { cwd: repoPath, stdio: "ignore" });
|
||||||
|
}
|
||||||
|
|
||||||
if (withRemote) {
|
if (withRemote) {
|
||||||
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
||||||
const remoteDir = path.join(repoPath, "remote.git");
|
const remoteDir = path.join(repoPath, "remote.git");
|
||||||
await mkdir(remoteDir, { recursive: true });
|
await mkdir(remoteDir, { recursive: true });
|
||||||
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync("git push -u origin main", { cwd: repoPath, stdio: "ignore" });
|
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: repoPath,
|
path: repoPath,
|
||||||
|
branchHeads,
|
||||||
cleanup: async () => {
|
cleanup: async () => {
|
||||||
await rm(repoPath, { recursive: true, force: true });
|
await rm(repoPath, { recursive: true, force: true });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function readWorktreeBranchInfo({ worktreePath }: { worktreePath: string }): Promise<{
|
||||||
|
currentBranch: string;
|
||||||
|
hasAncestor: (ref: string) => boolean;
|
||||||
|
}> {
|
||||||
|
const currentBranch = execSync("git branch --show-current", {
|
||||||
|
cwd: worktreePath,
|
||||||
|
stdio: "pipe",
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentBranch,
|
||||||
|
hasAncestor: (ref: string) => {
|
||||||
|
try {
|
||||||
|
execSync(`git merge-base --is-ancestor ${JSON.stringify(ref)} HEAD`, {
|
||||||
|
cwd: worktreePath,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import {
|
||||||
|
gotoWorkspace,
|
||||||
|
assertNewChatTileVisible,
|
||||||
|
assertTerminalTileVisible,
|
||||||
|
assertSingleNewTabButton,
|
||||||
|
clickNewTabButton,
|
||||||
|
pressNewTabShortcut,
|
||||||
|
clickNewChat,
|
||||||
|
clickTerminal,
|
||||||
|
countTabsOfKind,
|
||||||
|
getTabTestIds,
|
||||||
|
waitForTabWithTitle,
|
||||||
|
measureTileTransition,
|
||||||
|
sampleTabsDuringTransition,
|
||||||
|
} from "./helpers/launcher";
|
||||||
|
import {
|
||||||
|
connectTerminalClient,
|
||||||
|
waitForTerminalContent,
|
||||||
|
setupDeterministicPrompt,
|
||||||
|
type TerminalPerfDaemonClient,
|
||||||
|
} from "./helpers/terminal-perf";
|
||||||
|
|
||||||
|
// ─── Shared state ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||||
|
let workspaceId: string;
|
||||||
|
let seedClient: TerminalPerfDaemonClient;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
tempRepo = await createTempGitRepo("launcher-e2e-");
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (seedClient) await seedClient.close();
|
||||||
|
if (tempRepo) await tempRepo.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Tab Creation Tests
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
test.describe("Tab creation", () => {
|
||||||
|
test("Cmd+T opens a new agent tab with composer", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
|
||||||
|
await pressNewTabShortcut(page);
|
||||||
|
|
||||||
|
// Should show the composer directly (no launcher panel)
|
||||||
|
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
|
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opening two new tabs creates two draft tabs", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
|
||||||
|
const countBefore = await countTabsOfKind(page, "draft");
|
||||||
|
|
||||||
|
await pressNewTabShortcut(page);
|
||||||
|
await expect
|
||||||
|
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
|
||||||
|
.toBe(countBefore + 1);
|
||||||
|
const countAfterFirst = await countTabsOfKind(page, "draft");
|
||||||
|
|
||||||
|
// Blur the composer so the second shortcut isn't swallowed by the focused input
|
||||||
|
await page.evaluate(() => (document.activeElement as HTMLElement)?.blur?.());
|
||||||
|
await pressNewTabShortcut(page);
|
||||||
|
await expect
|
||||||
|
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
|
||||||
|
.toBe(countAfterFirst + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
|
||||||
|
await clickNewTabButton(page);
|
||||||
|
|
||||||
|
// Draft composer should appear (the agent message input)
|
||||||
|
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
|
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
|
const tabsAfter = await getTabTestIds(page);
|
||||||
|
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
|
||||||
|
expect(draftCountAfter).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking terminal button creates a standalone terminal", async ({ page }) => {
|
||||||
|
test.setTimeout(45_000);
|
||||||
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
|
||||||
|
await clickTerminal(page);
|
||||||
|
|
||||||
|
// Terminal surface should appear
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
const tabsAfter = await getTabTestIds(page);
|
||||||
|
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
|
||||||
|
expect(terminalTabs.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tab bar shows action buttons per pane", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
await assertSingleNewTabButton(page);
|
||||||
|
await assertNewChatTileVisible(page);
|
||||||
|
await assertTerminalTileVisible(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Terminal Title Tests
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
test.describe("Terminal title propagation", () => {
|
||||||
|
// OSC title escape sequence propagation is inherently flaky — the terminal
|
||||||
|
// must process the sequence, emit a title change event, and the tab bar
|
||||||
|
// must re-render before the assertion deadline. Allow retries.
|
||||||
|
test.describe.configure({ retries: 2 });
|
||||||
|
|
||||||
|
let client: TerminalPerfDaemonClient;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
client = await connectTerminalClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
if (client) await client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skip("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
const result = await client.createTerminal(tempRepo.path, "title-test");
|
||||||
|
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||||
|
const terminalId = result.terminal.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Navigate to workspace and open a terminal
|
||||||
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
await clickTerminal(page);
|
||||||
|
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
await terminal.first().click();
|
||||||
|
|
||||||
|
await setupDeterministicPrompt(page);
|
||||||
|
|
||||||
|
// Send OSC 0 (set window title) escape sequence
|
||||||
|
const testTitle = `E2E-Title-${Date.now()}`;
|
||||||
|
await terminal
|
||||||
|
.first()
|
||||||
|
.pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, { delay: 0 });
|
||||||
|
|
||||||
|
// Wait for the tab to reflect the new title
|
||||||
|
await waitForTabWithTitle(page, testTitle, 15_000);
|
||||||
|
} finally {
|
||||||
|
await client.killTerminal(terminalId).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skip("title debouncing coalesces rapid changes", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
const result = await client.createTerminal(tempRepo.path, "debounce-test");
|
||||||
|
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||||
|
const terminalId = result.terminal.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
await clickTerminal(page);
|
||||||
|
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
await terminal.first().click();
|
||||||
|
|
||||||
|
await setupDeterministicPrompt(page);
|
||||||
|
|
||||||
|
// Fire many rapid title changes — only the last should stick
|
||||||
|
const finalTitle = `Final-${Date.now()}`;
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await terminal
|
||||||
|
.first()
|
||||||
|
.pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, { delay: 0 });
|
||||||
|
}
|
||||||
|
await terminal
|
||||||
|
.first()
|
||||||
|
.pressSequentially(`printf '\\033]0;${finalTitle}\\007'\n`, { delay: 0 });
|
||||||
|
|
||||||
|
// The tab should eventually settle on the final title
|
||||||
|
await waitForTabWithTitle(page, finalTitle, 15_000);
|
||||||
|
} finally {
|
||||||
|
await client.killTerminal(terminalId).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// No-Flash Transition Tests
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
test.describe("Tab transitions (no flash)", () => {
|
||||||
|
test("New agent tab transition has no blank intermediate tab state", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
|
||||||
|
// Sample tabs at high frequency across the transition
|
||||||
|
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page), 2_000, 30);
|
||||||
|
|
||||||
|
// Every snapshot should have at least one tab — no blank/zero-tab frames
|
||||||
|
for (const snapshot of snapshots) {
|
||||||
|
expect(snapshot.length).toBeGreaterThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab count should never spike excessively (no duplicate flash from add-then-remove).
|
||||||
|
// When running in-suite, previous tests may have created tabs on the shared workspace,
|
||||||
|
// so we allow +2 tolerance for accumulated state and React render batching.
|
||||||
|
const counts = snapshots.map((s) => s.length);
|
||||||
|
const maxCount = Math.max(...counts);
|
||||||
|
const initialCount = counts[0] ?? 0;
|
||||||
|
|
||||||
|
expect(maxCount).toBeLessThanOrEqual(initialCount + 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Terminal transition completes within visual budget", async ({ page }) => {
|
||||||
|
test.setTimeout(30_000);
|
||||||
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
const elapsed = await measureTileTransition(
|
||||||
|
page,
|
||||||
|
() => clickTerminal(page),
|
||||||
|
terminal.first(),
|
||||||
|
20_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Terminal surface should appear within a reasonable budget.
|
||||||
|
// Note: terminal creation involves a server round-trip, so we allow more time
|
||||||
|
// than a pure in-memory transition, but it should still be well under 5 seconds.
|
||||||
|
expect(elapsed).toBeLessThan(5_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("New agent tab click shows composer without flash", async ({ page }) => {
|
||||||
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
|
||||||
|
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||||
|
|
||||||
|
const elapsed = await measureTileTransition(page, () => clickNewChat(page), composer, 10_000);
|
||||||
|
|
||||||
|
// Draft creation is fully in-memory — should be fast
|
||||||
|
// We use a generous budget here because CI can be slow, but the key assertion
|
||||||
|
// is that no blank/flash frame appears (tested above).
|
||||||
|
expect(elapsed).toBeLessThan(3_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import { expect, test } from "./fixtures";
|
import { expect, test } from "./fixtures";
|
||||||
|
import { gotoAppShell } from "./helpers/app";
|
||||||
import {
|
import {
|
||||||
archiveWorkspaceFromDaemon,
|
archiveWorkspaceFromDaemon,
|
||||||
archiveLocalWorkspaceFromDaemon,
|
archiveLocalWorkspaceFromDaemon,
|
||||||
@@ -7,12 +10,21 @@ import {
|
|||||||
clickNewWorkspaceButton,
|
clickNewWorkspaceButton,
|
||||||
connectNewWorkspaceDaemonClient,
|
connectNewWorkspaceDaemonClient,
|
||||||
createWorktreeViaDaemon,
|
createWorktreeViaDaemon,
|
||||||
|
expectComposerGithubAttachmentPill,
|
||||||
|
expectStartingRefPickerTriggerPr,
|
||||||
|
openNewWorkspaceComposer,
|
||||||
|
openStartingRefPicker,
|
||||||
openProjectViaDaemon,
|
openProjectViaDaemon,
|
||||||
|
selectBranchInPicker,
|
||||||
|
selectGitHubPrInPicker,
|
||||||
} from "./helpers/new-workspace";
|
} from "./helpers/new-workspace";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
||||||
import {
|
import {
|
||||||
|
expectSidebarWorkspaceSelected,
|
||||||
expectWorkspaceHeader,
|
expectWorkspaceHeader,
|
||||||
switchWorkspaceViaSidebar,
|
switchWorkspaceViaSidebar,
|
||||||
|
waitForSidebarHydration,
|
||||||
|
waitForWorkspaceInSidebar,
|
||||||
workspaceLabelFromPath,
|
workspaceLabelFromPath,
|
||||||
} from "./helpers/workspace-ui";
|
} from "./helpers/workspace-ui";
|
||||||
|
|
||||||
@@ -21,7 +33,7 @@ test.describe("New workspace flow", () => {
|
|||||||
const localWorkspaceIds = new Set<string>();
|
const localWorkspaceIds = new Set<string>();
|
||||||
const createdWorktreeIds = new Set<string>();
|
const createdWorktreeIds = new Set<string>();
|
||||||
|
|
||||||
test.describe.configure({ timeout: 120_000 });
|
test.describe.configure({ timeout: 240_000 });
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
test.beforeEach(async () => {
|
||||||
client = await connectNewWorkspaceDaemonClient();
|
client = await connectNewWorkspaceDaemonClient();
|
||||||
@@ -56,11 +68,17 @@ test.describe("New workspace flow", () => {
|
|||||||
localWorkspaceIds.add(firstWorkspace.workspaceId);
|
localWorkspaceIds.add(firstWorkspace.workspaceId);
|
||||||
localWorkspaceIds.add(secondWorkspace.workspaceId);
|
localWorkspaceIds.add(secondWorkspace.workspaceId);
|
||||||
|
|
||||||
await page.goto(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
|
await gotoAppShell(page);
|
||||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
|
await waitForSidebarHydration(page);
|
||||||
|
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
targetWorkspacePath: firstWorkspace.workspaceId,
|
||||||
|
});
|
||||||
await expectWorkspaceHeader(page, {
|
await expectWorkspaceHeader(page, {
|
||||||
title: firstWorkspace.workspaceName,
|
title: firstWorkspace.workspaceName,
|
||||||
subtitle: workspaceLabelFromPath(firstRepo.path),
|
subtitle: firstWorkspace.projectDisplayName,
|
||||||
});
|
});
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({
|
await switchWorkspaceViaSidebar({
|
||||||
@@ -68,9 +86,13 @@ test.describe("New workspace flow", () => {
|
|||||||
serverId,
|
serverId,
|
||||||
targetWorkspacePath: secondWorkspace.workspaceId,
|
targetWorkspacePath: secondWorkspace.workspaceId,
|
||||||
});
|
});
|
||||||
|
await waitForWorkspaceInSidebar(page, {
|
||||||
|
serverId,
|
||||||
|
workspaceId: secondWorkspace.workspaceId,
|
||||||
|
});
|
||||||
await expectWorkspaceHeader(page, {
|
await expectWorkspaceHeader(page, {
|
||||||
title: secondWorkspace.workspaceName,
|
title: secondWorkspace.workspaceName,
|
||||||
subtitle: workspaceLabelFromPath(secondRepo.path),
|
subtitle: secondWorkspace.projectDisplayName,
|
||||||
});
|
});
|
||||||
|
|
||||||
await switchWorkspaceViaSidebar({
|
await switchWorkspaceViaSidebar({
|
||||||
@@ -80,7 +102,7 @@ test.describe("New workspace flow", () => {
|
|||||||
});
|
});
|
||||||
await expectWorkspaceHeader(page, {
|
await expectWorkspaceHeader(page, {
|
||||||
title: firstWorkspace.workspaceName,
|
title: firstWorkspace.workspaceName,
|
||||||
subtitle: workspaceLabelFromPath(firstRepo.path),
|
subtitle: firstWorkspace.projectDisplayName,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await secondRepo.cleanup();
|
await secondRepo.cleanup();
|
||||||
@@ -88,6 +110,87 @@ test.describe("New workspace flow", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("same-project workspaces switch content without requiring refresh", async ({ page }) => {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const repo = await createTempGitRepo("workspace-nav-same-project-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rootWorkspace = await openProjectViaDaemon(client, repo.path);
|
||||||
|
const worktreeWorkspace = await createWorktreeViaDaemon(client, {
|
||||||
|
cwd: repo.path,
|
||||||
|
slug: `nav-${Date.now()}`,
|
||||||
|
});
|
||||||
|
localWorkspaceIds.add(rootWorkspace.workspaceId);
|
||||||
|
createdWorktreeIds.add(worktreeWorkspace.workspaceId);
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
targetWorkspacePath: rootWorkspace.workspaceId,
|
||||||
|
});
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: rootWorkspace.workspaceName,
|
||||||
|
subtitle: rootWorkspace.projectDisplayName,
|
||||||
|
});
|
||||||
|
await expectSidebarWorkspaceSelected({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
workspaceId: rootWorkspace.workspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
targetWorkspacePath: worktreeWorkspace.workspaceId,
|
||||||
|
});
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: worktreeWorkspace.workspaceName,
|
||||||
|
subtitle: worktreeWorkspace.projectDisplayName,
|
||||||
|
});
|
||||||
|
await expectSidebarWorkspaceSelected({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
workspaceId: worktreeWorkspace.workspaceId,
|
||||||
|
});
|
||||||
|
await expectSidebarWorkspaceSelected({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
workspaceId: rootWorkspace.workspaceId,
|
||||||
|
selected: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
targetWorkspacePath: rootWorkspace.workspaceId,
|
||||||
|
});
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: rootWorkspace.workspaceName,
|
||||||
|
subtitle: rootWorkspace.projectDisplayName,
|
||||||
|
});
|
||||||
|
await expectSidebarWorkspaceSelected({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
workspaceId: rootWorkspace.workspaceId,
|
||||||
|
});
|
||||||
|
await expectSidebarWorkspaceSelected({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
workspaceId: worktreeWorkspace.workspaceId,
|
||||||
|
selected: false,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one draft tab", async ({
|
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one draft tab", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -102,11 +205,17 @@ test.describe("New workspace flow", () => {
|
|||||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||||
localWorkspaceIds.add(openedProject.workspaceId);
|
localWorkspaceIds.add(openedProject.workspaceId);
|
||||||
|
|
||||||
await page.goto(buildHostWorkspaceRoute(serverId, openedProject.workspaceId));
|
await gotoAppShell(page);
|
||||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, openedProject.workspaceId));
|
await waitForSidebarHydration(page);
|
||||||
|
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
targetWorkspacePath: openedProject.workspaceId,
|
||||||
|
});
|
||||||
await expectWorkspaceHeader(page, {
|
await expectWorkspaceHeader(page, {
|
||||||
title: openedProject.workspaceName,
|
title: openedProject.workspaceName,
|
||||||
subtitle: workspaceLabelFromPath(tempRepo.path),
|
subtitle: openedProject.projectDisplayName,
|
||||||
});
|
});
|
||||||
|
|
||||||
await clickNewWorkspaceButton(page, {
|
await clickNewWorkspaceButton(page, {
|
||||||
@@ -139,10 +248,18 @@ test.describe("New workspace flow", () => {
|
|||||||
subtitle: openedProject.projectDisplayName,
|
subtitle: openedProject.projectDisplayName,
|
||||||
});
|
});
|
||||||
|
|
||||||
const draftTabs = page.locator('[data-testid^="workspace-tab-"]').filter({
|
const activeWorkspaceDeckEntry = page
|
||||||
has: page.getByText("New Agent", { exact: true }),
|
.getByTestId(`workspace-deck-entry-${serverId}:${createdWorkspace.workspaceId}`)
|
||||||
});
|
.filter({ visible: true });
|
||||||
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
|
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const agentTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]');
|
||||||
|
await expect(agentTabs).toHaveCount(1, { timeout: 30_000 });
|
||||||
|
|
||||||
|
// Workspace setup may auto-open a setup tab that steals focus,
|
||||||
|
// hiding the agent panel (display:none removes it from the
|
||||||
|
// accessibility tree). Click the agent tab to ensure it's active.
|
||||||
|
await agentTabs.first().click();
|
||||||
|
|
||||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
await expect(composer).toBeEditable({ timeout: 30_000 });
|
||||||
@@ -150,4 +267,94 @@ test.describe("New workspace flow", () => {
|
|||||||
await tempRepo.cleanup();
|
await tempRepo.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempRepo = await createTempGitRepo("new-workspace-ref-", {
|
||||||
|
branches: ["main", "dev"],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||||
|
localWorkspaceIds.add(openedProject.workspaceId);
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
targetWorkspacePath: openedProject.workspaceId,
|
||||||
|
});
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: openedProject.workspaceName,
|
||||||
|
subtitle: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
await openNewWorkspaceComposer(page, {
|
||||||
|
projectKey: openedProject.projectKey,
|
||||||
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
await openStartingRefPicker(page);
|
||||||
|
await selectBranchInPicker(page, "dev");
|
||||||
|
|
||||||
|
const createButton = page
|
||||||
|
.getByTestId("message-input-root")
|
||||||
|
.getByRole("button", { name: "Create" });
|
||||||
|
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||||
|
await createButton.click();
|
||||||
|
|
||||||
|
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||||
|
serverId,
|
||||||
|
previousWorkspaceId: openedProject.workspaceId,
|
||||||
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||||
|
|
||||||
|
expect(existsSync(createdWorkspace.workspaceId)).toBe(true);
|
||||||
|
|
||||||
|
const branchInfo = await readWorktreeBranchInfo({
|
||||||
|
worktreePath: createdWorkspace.workspaceId,
|
||||||
|
});
|
||||||
|
expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceId));
|
||||||
|
expect(branchInfo.hasAncestor(tempRepo.branchHeads.main)).toBe(true);
|
||||||
|
expect(branchInfo.hasAncestor(tempRepo.branchHeads.dev)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await tempRepo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
|
||||||
|
const tempRepo = await createTempGitRepo("new-workspace-pr-ref-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||||
|
localWorkspaceIds.add(openedProject.workspaceId);
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await openNewWorkspaceComposer(page, {
|
||||||
|
projectKey: openedProject.projectKey,
|
||||||
|
projectDisplayName: openedProject.projectDisplayName,
|
||||||
|
});
|
||||||
|
await openStartingRefPicker(page);
|
||||||
|
await selectGitHubPrInPicker(page, 515);
|
||||||
|
|
||||||
|
await expectStartingRefPickerTriggerPr(page, {
|
||||||
|
number: 515,
|
||||||
|
title: "Review selected start ref",
|
||||||
|
headRef: "feature/start-from-pr",
|
||||||
|
});
|
||||||
|
await expectComposerGithubAttachmentPill(page, {
|
||||||
|
number: 515,
|
||||||
|
title: "Review selected start ref",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await tempRepo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { test, expect, type Page } from "./fixtures";
|
||||||
|
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||||
|
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
|
||||||
|
|
||||||
|
function getSeededServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSeededDaemonPort(): string {
|
||||||
|
const port = process.env.E2E_DAEMON_PORT;
|
||||||
|
if (!port) {
|
||||||
|
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
|
||||||
|
}
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openHostPage(page: Page, serverId: string) {
|
||||||
|
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||||
|
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectHostLabelHeader(page: Page) {
|
||||||
|
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(TEST_HOST_LABEL);
|
||||||
|
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Settings host page", () => {
|
||||||
|
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const serverId = getSeededServerId();
|
||||||
|
const port = getSeededDaemonPort();
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openSettings(page);
|
||||||
|
await openHostPage(page, serverId);
|
||||||
|
|
||||||
|
// Label renders in the detail header with a pencil edit affordance; the input is hidden until edit.
|
||||||
|
await expectHostLabelHeader(page);
|
||||||
|
|
||||||
|
// Connections is its own section with a "Connections" heading and the seeded endpoint row.
|
||||||
|
const connectionsCard = page.getByTestId("host-page-connections-card");
|
||||||
|
await expect(connectionsCard).toBeVisible();
|
||||||
|
await expect(page.getByText("Connections", { exact: true })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
connectionsCard.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
const injectMcpCard = page.getByTestId("host-page-inject-mcp-card");
|
||||||
|
await expect(injectMcpCard).toBeVisible();
|
||||||
|
await expect(injectMcpCard.getByRole("button", { name: "On", exact: true })).toBeVisible();
|
||||||
|
await expect(injectMcpCard.getByRole("button", { name: "Off", exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
|
||||||
|
const serverId = getSeededServerId();
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openSettings(page);
|
||||||
|
await openHostPage(page, serverId);
|
||||||
|
|
||||||
|
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByTestId("host-page-label-edit-button").click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("host-page-label-input")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("host-page-label-input")).toHaveValue(TEST_HOST_LABEL);
|
||||||
|
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const serverId = getSeededServerId();
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openSettings(page);
|
||||||
|
await openHostPage(page, serverId);
|
||||||
|
|
||||||
|
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
|
||||||
|
// Pair-device now lives behind a row that only the local host sees
|
||||||
|
// (gated by useIsLocalDaemon); the seeded host is remote, so it must
|
||||||
|
// not appear. The daemon-lifecycle card is still local-host only.
|
||||||
|
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settings sidebar does not expose retired top-level sections", async ({ page }) => {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openSettings(page);
|
||||||
|
|
||||||
|
const sidebar = page.getByTestId("settings-sidebar");
|
||||||
|
await expect(sidebar).toBeVisible();
|
||||||
|
|
||||||
|
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
|
||||||
|
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0);
|
||||||
|
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
|
||||||
|
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
|
||||||
|
|
||||||
|
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
|
||||||
|
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
|
||||||
|
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const serverId = getSeededServerId();
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
|
||||||
|
|
||||||
|
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||||
|
await expectHostLabelHeader(page);
|
||||||
|
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
|
||||||
|
const serverId = getSeededServerId();
|
||||||
|
|
||||||
|
// Simulate the Electron desktop bridge so `useIsLocalDaemon` resolves the
|
||||||
|
// seeded host to the local daemon. `manageBuiltInDaemon: false` bypasses
|
||||||
|
// the desktop bootstrap flow so only the sidebar's status query runs.
|
||||||
|
await page.addInitScript((localServerId) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"@paseo:app-settings",
|
||||||
|
JSON.stringify({ theme: "auto", manageBuiltInDaemon: false, sendBehavior: "interrupt" }),
|
||||||
|
);
|
||||||
|
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
||||||
|
platform: "darwin",
|
||||||
|
invoke: async (command: string) => {
|
||||||
|
if (command === "desktop_daemon_status") {
|
||||||
|
return {
|
||||||
|
serverId: localServerId,
|
||||||
|
status: "running",
|
||||||
|
listen: null,
|
||||||
|
hostname: null,
|
||||||
|
pid: null,
|
||||||
|
home: "",
|
||||||
|
version: null,
|
||||||
|
desktopManaged: true,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
getPendingOpenProject: async () => null,
|
||||||
|
events: { on: async () => () => undefined },
|
||||||
|
};
|
||||||
|
}, serverId);
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openSettings(page);
|
||||||
|
|
||||||
|
const sidebar = page.getByTestId("settings-sidebar");
|
||||||
|
await expect(sidebar).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
const hostEntries = sidebar.locator('[data-testid^="settings-host-entry-"]');
|
||||||
|
await expect(hostEntries.first()).toHaveAttribute(
|
||||||
|
"data-testid",
|
||||||
|
`settings-host-entry-${serverId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
|
||||||
|
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
|
||||||
|
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { test, expect, type Page } from "./fixtures";
|
||||||
|
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||||
|
|
||||||
|
function escapeRegex(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickSidebarSection(page: Page, label: string) {
|
||||||
|
const sidebar = page.getByTestId("settings-sidebar");
|
||||||
|
await expect(sidebar).toBeVisible();
|
||||||
|
await sidebar.getByRole("button", { name: label, exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Settings sidebar navigation", () => {
|
||||||
|
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openSettings(page);
|
||||||
|
|
||||||
|
await clickSidebarSection(page, "Diagnostics");
|
||||||
|
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
|
||||||
|
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
||||||
|
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("Diagnostics");
|
||||||
|
|
||||||
|
await clickSidebarSection(page, "About");
|
||||||
|
await expect(page).toHaveURL(/\/settings\/about$/);
|
||||||
|
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible();
|
||||||
|
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("About");
|
||||||
|
|
||||||
|
await clickSidebarSection(page, "General");
|
||||||
|
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||||
|
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
|
||||||
|
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("General");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => {
|
||||||
|
const serverId = getServerId();
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the + Add host button opens the add-host method modal", async ({ page }) => {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openSettings(page);
|
||||||
|
|
||||||
|
await page.getByTestId("settings-add-host").click();
|
||||||
|
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Direct connection" })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await openSettings(page);
|
||||||
|
|
||||||
|
const backRow = page.getByTestId("settings-back-to-workspace");
|
||||||
|
await expect(backRow).toBeVisible();
|
||||||
|
|
||||||
|
await backRow.click();
|
||||||
|
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Settings — compact master-detail", () => {
|
||||||
|
test.use({ viewport: { width: 390, height: 844 } });
|
||||||
|
|
||||||
|
async function openCompactSettingsRoot(page: Page) {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
// Wait for bootstrap to settle on a host route so storeReady is true before
|
||||||
|
// we navigate into the protected settings stack. A direct page.goto("/settings")
|
||||||
|
// on a cold load is eaten by Stack.Protected while bootstrap is still running.
|
||||||
|
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
|
||||||
|
|
||||||
|
// Drive navigation the same way a user would: open the mobile drawer and tap
|
||||||
|
// the Settings footer icon. This preserves the in-app router state instead of
|
||||||
|
// triggering a full reload through Stack.Protected.
|
||||||
|
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||||
|
const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||||
|
await expect(sidebarSettingsButton).toBeVisible();
|
||||||
|
await sidebarSettingsButton.click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/settings$/);
|
||||||
|
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectCompactSettingsRootList(page: Page) {
|
||||||
|
await expect(page).toHaveURL(/\/settings$/);
|
||||||
|
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
|
||||||
|
await openCompactSettingsRoot(page);
|
||||||
|
|
||||||
|
// Sidebar rows are present.
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("settings-sidebar").getByRole("button", { name: "General", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByTestId("settings-sidebar")
|
||||||
|
.getByRole("button", { name: "Diagnostics", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("settings-sidebar").getByRole("button", { name: "About", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Section detail content is NOT rendered at the root.
|
||||||
|
await expectCompactSettingsRootList(page);
|
||||||
|
|
||||||
|
const rootBackButton = page.getByRole("button", { name: "Back", exact: true });
|
||||||
|
await expect(rootBackButton).toBeVisible();
|
||||||
|
await rootBackButton.click();
|
||||||
|
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
|
||||||
|
await openCompactSettingsRoot(page);
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByTestId("settings-sidebar")
|
||||||
|
.getByRole("button", { name: "Diagnostics", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
|
||||||
|
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
||||||
|
// Sidebar is no longer visible — we are on a detail screen.
|
||||||
|
// (Expo Router stack keeps the previous screen in the DOM but hidden; check
|
||||||
|
// only visible instances.)
|
||||||
|
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("back from a section detail returns to the /settings list", async ({ page }) => {
|
||||||
|
await openCompactSettingsRoot(page);
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByTestId("settings-sidebar")
|
||||||
|
.getByRole("button", { name: "About", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(page).toHaveURL(/\/settings\/about$/);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||||
|
await expectCompactSettingsRootList(page);
|
||||||
|
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
|
||||||
|
const serverId = getServerId();
|
||||||
|
await openCompactSettingsRoot(page);
|
||||||
|
|
||||||
|
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||||
|
);
|
||||||
|
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||||
|
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("back from a host detail returns to the /settings list", async ({ page }) => {
|
||||||
|
const serverId = getServerId();
|
||||||
|
await openCompactSettingsRoot(page);
|
||||||
|
|
||||||
|
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||||
|
);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/settings$/);
|
||||||
|
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { gotoAppShell } from "./helpers/app";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import { expectWorkspaceHeader } from "./helpers/workspace-ui";
|
||||||
|
import { connectWorkspaceSetupClient } from "./helpers/workspace-setup";
|
||||||
|
|
||||||
|
function getServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorkspaceRowTestId(workspaceId: string): string {
|
||||||
|
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGitHubRemote(repoPath: string): void {
|
||||||
|
execSync("git remote set-url origin https://github.com/test-owner/test-repo.git", {
|
||||||
|
cwd: repoPath,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTempDirectory(prefix = "paseo-e2e-dir-") {
|
||||||
|
const tempRoot = process.platform === "win32" ? tmpdir() : await realpath("/tmp");
|
||||||
|
const dirPath = await mkdtemp(path.join(tempRoot, prefix));
|
||||||
|
await writeFile(path.join(dirPath, "README.md"), "# Temp Directory\n");
|
||||||
|
return {
|
||||||
|
path: dirPath,
|
||||||
|
cleanup: async () => {
|
||||||
|
await rm(dirPath, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openProjectViaDaemon(
|
||||||
|
client: Awaited<ReturnType<typeof connectWorkspaceSetupClient>>,
|
||||||
|
cwd: string,
|
||||||
|
): Promise<{ id: string; name: string }> {
|
||||||
|
const result = await client.openProject(cwd);
|
||||||
|
if (!result.workspace || result.error) {
|
||||||
|
throw new Error(result.error ?? `Failed to open project ${cwd}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: String(result.workspace.id),
|
||||||
|
name: result.workspace.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openWorkspaceFromSidebar(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
workspaceId: string,
|
||||||
|
) {
|
||||||
|
const row = page.getByTestId(getWorkspaceRowTestId(workspaceId));
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
await row.click();
|
||||||
|
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSidebarProject(page: import("@playwright/test").Page, projectName: string) {
|
||||||
|
const row = page
|
||||||
|
.getByRole("button", {
|
||||||
|
name: new RegExp(escapeRegex(projectName), "i"),
|
||||||
|
})
|
||||||
|
.first();
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSidebarWorkspace(page: import("@playwright/test").Page, workspaceId: string) {
|
||||||
|
const row = page.getByTestId(getWorkspaceRowTestId(workspaceId));
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Sidebar workspace list", () => {
|
||||||
|
test("project with GitHub remote shows owner/repo name in sidebar", async ({ page }) => {
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("sidebar-remote-", { withRemote: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
setGitHubRemote(repo.path);
|
||||||
|
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarProject(page, "test-owner/test-repo");
|
||||||
|
await waitForSidebarWorkspace(page, workspace.id);
|
||||||
|
|
||||||
|
const projectRow = page
|
||||||
|
.locator('[data-testid^="sidebar-project-row-"]')
|
||||||
|
.filter({ hasText: "test-owner/test-repo" })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(projectRow).not.toContainText(path.basename(repo.path));
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("project shows workspace under it", async ({ page }) => {
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("sidebar-workspace-under-project-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||||
|
await gotoAppShell(page);
|
||||||
|
|
||||||
|
await waitForSidebarProject(page, path.basename(repo.path));
|
||||||
|
await waitForSidebarWorkspace(page, workspace.id);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-git project shows directory name", async ({ page }) => {
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const project = await createTempDirectory("sidebar-directory-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await openProjectViaDaemon(client, project.path);
|
||||||
|
await gotoAppShell(page);
|
||||||
|
|
||||||
|
const projectRow = await waitForSidebarProject(page, path.basename(project.path));
|
||||||
|
await expect(projectRow).toContainText(path.basename(project.path));
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await project.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("workspace header shows correct title and subtitle", async ({ page }) => {
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("sidebar-header-", { withRemote: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
setGitHubRemote(repo.path);
|
||||||
|
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarProject(page, "test-owner/test-repo");
|
||||||
|
await waitForSidebarWorkspace(page, workspace.id);
|
||||||
|
await openWorkspaceFromSidebar(page, workspace.id);
|
||||||
|
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: workspace.name,
|
||||||
|
subtitle: "test-owner/test-repo",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("git project shows branch name in workspace row", async ({ page }) => {
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("sidebar-branch-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarProject(page, path.basename(repo.path));
|
||||||
|
|
||||||
|
expect(workspace.name).toBe("main");
|
||||||
|
await expect(await waitForSidebarWorkspace(page, workspace.id)).toContainText("main");
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,10 +20,15 @@ const KEYSTROKE_P95_BUDGET_MS = 150;
|
|||||||
test.describe("Terminal wire performance", () => {
|
test.describe("Terminal wire performance", () => {
|
||||||
let client: TerminalPerfDaemonClient;
|
let client: TerminalPerfDaemonClient;
|
||||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||||
|
let workspaceId: string;
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
tempRepo = await createTempGitRepo("perf-");
|
tempRepo = await createTempGitRepo("perf-");
|
||||||
client = await connectTerminalClient();
|
client = await connectTerminalClient();
|
||||||
|
// Seed the workspace in the daemon so the app can resolve the path
|
||||||
|
const seedResult = await client.openProject(tempRepo.path);
|
||||||
|
if (!seedResult.workspace) throw new Error(seedResult.error ?? "Failed to seed workspace");
|
||||||
|
workspaceId = seedResult.workspace.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test.afterAll(async () => {
|
||||||
@@ -45,7 +50,7 @@ test.describe("Terminal wire performance", () => {
|
|||||||
const terminalId = result.terminal.id;
|
const terminalId = result.terminal.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
|
await navigateToTerminal(page, { workspaceId, terminalId });
|
||||||
await setupDeterministicPrompt(page);
|
await setupDeterministicPrompt(page);
|
||||||
|
|
||||||
const sentinel = `PERF_DONE_${Date.now()}`;
|
const sentinel = `PERF_DONE_${Date.now()}`;
|
||||||
@@ -104,7 +109,7 @@ test.describe("Terminal wire performance", () => {
|
|||||||
const terminalId = result.terminal.id;
|
const terminalId = result.terminal.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
|
await navigateToTerminal(page, { workspaceId, terminalId });
|
||||||
await setupDeterministicPrompt(page);
|
await setupDeterministicPrompt(page);
|
||||||
|
|
||||||
// Ensure clean prompt state
|
// Ensure clean prompt state
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { realpathSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, test } from "./fixtures";
|
||||||
|
import { clickTerminal, waitForTabBar } from "./helpers/launcher";
|
||||||
|
import { setupDeterministicPrompt, waitForTerminalContent } from "./helpers/terminal-perf";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import {
|
||||||
|
connectWorkspaceSetupClient,
|
||||||
|
openHomeWithProject,
|
||||||
|
seedProjectForWorkspaceSetup,
|
||||||
|
} from "./helpers/workspace-setup";
|
||||||
|
|
||||||
|
function getServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navigate to a workspace via sidebar row testID and wait for tab bar. */
|
||||||
|
async function navigateToWorkspaceViaSidebar(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||||
|
const row = page.getByTestId(testId);
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
await row.click();
|
||||||
|
await waitForTabBar(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Workspace cwd correctness", () => {
|
||||||
|
test("main checkout workspace opens terminals in the project root", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("workspace-cwd-main-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
|
||||||
|
const workspaceResult = await client.openProject(repo.path);
|
||||||
|
if (!workspaceResult.workspace) {
|
||||||
|
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||||
|
}
|
||||||
|
const workspaceId = String(workspaceResult.workspace.id);
|
||||||
|
|
||||||
|
// Use sidebar navigation to avoid Expo Router hydration issues
|
||||||
|
await openHomeWithProject(page, repo.path);
|
||||||
|
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||||
|
await clickTerminal(page);
|
||||||
|
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
await terminal.first().click();
|
||||||
|
|
||||||
|
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
||||||
|
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
|
||||||
|
|
||||||
|
await waitForTerminalContent(page, (text) => text.includes(repo.path), 10_000);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("worktree workspace opens terminals in the worktree directory", async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("workspace-cwd-worktree-");
|
||||||
|
const resolvedTmp = realpathSync("/tmp");
|
||||||
|
const worktreePath = path.join(
|
||||||
|
resolvedTmp,
|
||||||
|
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
);
|
||||||
|
const branchName = `workspace-cwd-${Date.now()}`;
|
||||||
|
let worktreeCreated = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
|
||||||
|
execSync(
|
||||||
|
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
|
||||||
|
{
|
||||||
|
cwd: repo.path,
|
||||||
|
stdio: "ignore",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
worktreeCreated = true;
|
||||||
|
|
||||||
|
const workspaceResult = await client.openProject(worktreePath);
|
||||||
|
if (!workspaceResult.workspace) {
|
||||||
|
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||||
|
}
|
||||||
|
const workspaceId = String(workspaceResult.workspace.id);
|
||||||
|
|
||||||
|
// Use sidebar navigation to avoid Expo Router hydration issues
|
||||||
|
// with direct URL navigation to the 2nd+ workspace.
|
||||||
|
await openHomeWithProject(page, repo.path);
|
||||||
|
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||||
|
|
||||||
|
await clickTerminal(page);
|
||||||
|
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
await terminal.first().click();
|
||||||
|
|
||||||
|
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
||||||
|
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
|
||||||
|
await waitForTerminalContent(page, (text) => text.includes(worktreePath), 10_000);
|
||||||
|
} finally {
|
||||||
|
if (worktreeCreated) {
|
||||||
|
try {
|
||||||
|
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||||
|
cwd: repo.path,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort cleanup so test failures preserve the original error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { realpathSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, test } from "./fixtures";
|
||||||
|
import { waitForTabBar } from "./helpers/launcher";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import {
|
||||||
|
createAgentChatFromLauncher,
|
||||||
|
createStandaloneTerminalFromLauncher,
|
||||||
|
expectTerminalCwd,
|
||||||
|
} from "./helpers/workspace-lifecycle";
|
||||||
|
import {
|
||||||
|
connectWorkspaceSetupClient,
|
||||||
|
openHomeWithProject,
|
||||||
|
seedProjectForWorkspaceSetup,
|
||||||
|
} from "./helpers/workspace-setup";
|
||||||
|
|
||||||
|
function getServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navigate to a workspace via sidebar row testID and wait for the tab bar. */
|
||||||
|
async function navigateToWorkspaceViaSidebar(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||||
|
const row = page.getByTestId(testId);
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
await row.click();
|
||||||
|
await waitForTabBar(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Workspace lifecycle", () => {
|
||||||
|
// The first test after a spec-file switch can intermittently fail because
|
||||||
|
// the shared daemon still holds stale sessions from the previous spec.
|
||||||
|
// One retry is enough for the daemon to stabilize.
|
||||||
|
test.describe.configure({ retries: 1 });
|
||||||
|
|
||||||
|
test.describe("Main checkout", () => {
|
||||||
|
test("creates an agent chat via New Chat", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("lifecycle-main-chat-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
const workspaceResult = await client.openProject(repo.path);
|
||||||
|
if (!workspaceResult.workspace) {
|
||||||
|
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||||
|
}
|
||||||
|
const workspaceId = String(workspaceResult.workspace.id);
|
||||||
|
|
||||||
|
await openHomeWithProject(page, repo.path);
|
||||||
|
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||||
|
await createAgentChatFromLauncher(page);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates a terminal with correct CWD", async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("lifecycle-main-shell-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
const workspaceResult = await client.openProject(repo.path);
|
||||||
|
if (!workspaceResult.workspace) {
|
||||||
|
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||||
|
}
|
||||||
|
const workspaceId = String(workspaceResult.workspace.id);
|
||||||
|
|
||||||
|
await openHomeWithProject(page, repo.path);
|
||||||
|
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||||
|
await createStandaloneTerminalFromLauncher(page);
|
||||||
|
await expectTerminalCwd(page, repo.path);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Worktree workspace", () => {
|
||||||
|
test("creates an agent chat via New Chat", async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("lifecycle-wt-chat-");
|
||||||
|
const resolvedTmp = realpathSync("/tmp");
|
||||||
|
const worktreePath = path.join(
|
||||||
|
resolvedTmp,
|
||||||
|
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
);
|
||||||
|
const branchName = `lifecycle-wt-chat-${Date.now()}`;
|
||||||
|
let worktreeCreated = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
|
||||||
|
execSync(
|
||||||
|
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
|
||||||
|
{
|
||||||
|
cwd: repo.path,
|
||||||
|
stdio: "ignore",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
worktreeCreated = true;
|
||||||
|
|
||||||
|
const workspaceResult = await client.openProject(worktreePath);
|
||||||
|
if (!workspaceResult.workspace) {
|
||||||
|
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||||
|
}
|
||||||
|
const workspaceId = String(workspaceResult.workspace.id);
|
||||||
|
|
||||||
|
await openHomeWithProject(page, repo.path);
|
||||||
|
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||||
|
await createAgentChatFromLauncher(page);
|
||||||
|
} finally {
|
||||||
|
if (worktreeCreated) {
|
||||||
|
try {
|
||||||
|
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||||
|
cwd: repo.path,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort cleanup so test failures preserve the original error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("creates a terminal with correct CWD", async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("lifecycle-wt-shell-");
|
||||||
|
const resolvedTmp = realpathSync("/tmp");
|
||||||
|
const worktreePath = path.join(
|
||||||
|
resolvedTmp,
|
||||||
|
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
);
|
||||||
|
const branchName = `lifecycle-wt-shell-${Date.now()}`;
|
||||||
|
let worktreeCreated = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
|
||||||
|
execSync(
|
||||||
|
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
|
||||||
|
{
|
||||||
|
cwd: repo.path,
|
||||||
|
stdio: "ignore",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
worktreeCreated = true;
|
||||||
|
|
||||||
|
const workspaceResult = await client.openProject(worktreePath);
|
||||||
|
if (!workspaceResult.workspace) {
|
||||||
|
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||||
|
}
|
||||||
|
const workspaceId = String(workspaceResult.workspace.id);
|
||||||
|
|
||||||
|
await openHomeWithProject(page, repo.path);
|
||||||
|
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||||
|
await createStandaloneTerminalFromLauncher(page);
|
||||||
|
await expectTerminalCwd(page, worktreePath);
|
||||||
|
} finally {
|
||||||
|
if (worktreeCreated) {
|
||||||
|
try {
|
||||||
|
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||||
|
cwd: repo.path,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Best-effort cleanup so test failures preserve the original error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
|
import { expect, test } from "./fixtures";
|
||||||
|
import { gotoAppShell } from "./helpers/app";
|
||||||
|
import {
|
||||||
|
archiveAgentFromDaemon,
|
||||||
|
connectArchiveTabDaemonClient,
|
||||||
|
createIdleAgent,
|
||||||
|
expectWorkspaceTabHidden,
|
||||||
|
expectWorkspaceTabVisible,
|
||||||
|
openWorkspaceWithAgents,
|
||||||
|
} from "./helpers/archive-tab";
|
||||||
|
import {
|
||||||
|
archiveLocalWorkspaceFromDaemon,
|
||||||
|
connectNewWorkspaceDaemonClient,
|
||||||
|
openProjectViaDaemon,
|
||||||
|
} from "./helpers/new-workspace";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import {
|
||||||
|
getVisibleWorkspaceAgentTabIds,
|
||||||
|
expectOnlyWorkspaceAgentTabsVisible,
|
||||||
|
waitForWorkspaceTabsVisible,
|
||||||
|
} from "./helpers/workspace-tabs";
|
||||||
|
import {
|
||||||
|
expectSidebarWorkspaceSelected,
|
||||||
|
expectWorkspaceHeader,
|
||||||
|
switchWorkspaceViaSidebar,
|
||||||
|
waitForSidebarHydration,
|
||||||
|
} from "./helpers/workspace-ui";
|
||||||
|
|
||||||
|
test.describe("Workspace navigation regression", () => {
|
||||||
|
test.describe.configure({ timeout: 240_000 });
|
||||||
|
|
||||||
|
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceClient = await connectNewWorkspaceDaemonClient();
|
||||||
|
const archiveClient = await connectArchiveTabDaemonClient();
|
||||||
|
const workspaceIds = new Set<string>();
|
||||||
|
const agentIds: string[] = [];
|
||||||
|
const firstRepo = await createTempGitRepo("workspace-nav-reg-a-");
|
||||||
|
const secondRepo = await createTempGitRepo("workspace-nav-reg-b-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstWorkspace = await openProjectViaDaemon(workspaceClient, firstRepo.path);
|
||||||
|
const secondWorkspace = await openProjectViaDaemon(workspaceClient, secondRepo.path);
|
||||||
|
workspaceIds.add(firstWorkspace.workspaceId);
|
||||||
|
workspaceIds.add(secondWorkspace.workspaceId);
|
||||||
|
|
||||||
|
const firstAgent = await createIdleAgent(archiveClient, {
|
||||||
|
cwd: firstRepo.path,
|
||||||
|
title: `workspace-nav-a-${Date.now()}`,
|
||||||
|
});
|
||||||
|
const secondAgent = await createIdleAgent(archiveClient, {
|
||||||
|
cwd: secondRepo.path,
|
||||||
|
title: `workspace-nav-b-${Date.now()}`,
|
||||||
|
});
|
||||||
|
agentIds.push(firstAgent.id, secondAgent.id);
|
||||||
|
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||||
|
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
targetWorkspacePath: firstWorkspace.workspaceId,
|
||||||
|
});
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expectSidebarWorkspaceSelected({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
workspaceId: firstWorkspace.workspaceId,
|
||||||
|
});
|
||||||
|
await expectSidebarWorkspaceSelected({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
workspaceId: secondWorkspace.workspaceId,
|
||||||
|
selected: false,
|
||||||
|
});
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: firstWorkspace.workspaceName,
|
||||||
|
subtitle: firstWorkspace.projectDisplayName,
|
||||||
|
});
|
||||||
|
await expectWorkspaceTabVisible(page, firstAgent.id);
|
||||||
|
await expectWorkspaceTabHidden(page, secondAgent.id);
|
||||||
|
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
|
||||||
|
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
||||||
|
`workspace-tab-agent_${firstAgent.id}`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const firstDeckEntry = page.getByTestId(
|
||||||
|
`workspace-deck-entry-${serverId}:${firstWorkspace.workspaceId}`,
|
||||||
|
);
|
||||||
|
const secondDeckEntry = page.getByTestId(
|
||||||
|
`workspace-deck-entry-${serverId}:${secondWorkspace.workspaceId}`,
|
||||||
|
);
|
||||||
|
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
targetWorkspacePath: secondWorkspace.workspaceId,
|
||||||
|
});
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expectSidebarWorkspaceSelected({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
workspaceId: secondWorkspace.workspaceId,
|
||||||
|
});
|
||||||
|
await expectSidebarWorkspaceSelected({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
workspaceId: firstWorkspace.workspaceId,
|
||||||
|
selected: false,
|
||||||
|
});
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: secondWorkspace.workspaceName,
|
||||||
|
subtitle: secondWorkspace.projectDisplayName,
|
||||||
|
});
|
||||||
|
await expectWorkspaceTabVisible(page, secondAgent.id);
|
||||||
|
await expectWorkspaceTabHidden(page, firstAgent.id);
|
||||||
|
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
|
||||||
|
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
||||||
|
`workspace-tab-agent_${secondAgent.id}`,
|
||||||
|
]);
|
||||||
|
await expect(firstDeckEntry).toBeAttached();
|
||||||
|
await expect(firstDeckEntry).toBeHidden();
|
||||||
|
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
||||||
|
|
||||||
|
await switchWorkspaceViaSidebar({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
targetWorkspacePath: firstWorkspace.workspaceId,
|
||||||
|
});
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(secondDeckEntry).toBeAttached();
|
||||||
|
await expect(secondDeckEntry).toBeHidden();
|
||||||
|
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expectSidebarWorkspaceSelected({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
workspaceId: firstWorkspace.workspaceId,
|
||||||
|
});
|
||||||
|
await expectWorkspaceHeader(page, {
|
||||||
|
title: firstWorkspace.workspaceName,
|
||||||
|
subtitle: firstWorkspace.projectDisplayName,
|
||||||
|
});
|
||||||
|
await expectWorkspaceTabVisible(page, firstAgent.id);
|
||||||
|
await expectWorkspaceTabHidden(page, secondAgent.id);
|
||||||
|
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
|
||||||
|
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
||||||
|
`workspace-tab-agent_${firstAgent.id}`,
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
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 secondRepo.cleanup();
|
||||||
|
await firstRepo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { expect, test } from "./fixtures";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import { clickTerminal, waitForTabBar } from "./helpers/launcher";
|
||||||
|
import {
|
||||||
|
connectWorkspaceSetupClient,
|
||||||
|
createWorkspaceThroughDaemon,
|
||||||
|
findWorktreeWorkspaceForProject,
|
||||||
|
openHomeWithProject,
|
||||||
|
} from "./helpers/workspace-setup";
|
||||||
|
|
||||||
|
function getServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateToWorkspaceViaSidebar(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
await row.click();
|
||||||
|
await waitForTabBar(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Workspace setup runtime authority", () => {
|
||||||
|
test.describe.configure({ retries: 1 });
|
||||||
|
|
||||||
|
test("worktree workspace is created in its own directory", async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("workspace-setup-chat-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.openProject(repo.path);
|
||||||
|
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||||
|
cwd: repo.path,
|
||||||
|
worktreeSlug: `setup-chat-${Date.now()}`,
|
||||||
|
});
|
||||||
|
const workspaceId = String(workspace.id);
|
||||||
|
|
||||||
|
const wsInfo = await findWorktreeWorkspaceForProject(client, repo.path);
|
||||||
|
expect(wsInfo.workspaceDirectory).not.toBe(repo.path);
|
||||||
|
expect(existsSync(wsInfo.workspaceDirectory)).toBe(true);
|
||||||
|
|
||||||
|
// Navigate to the workspace via sidebar
|
||||||
|
await openHomeWithProject(page, repo.path);
|
||||||
|
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||||
|
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("first terminal opens in the created workspace directory", async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("workspace-setup-terminal-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.openProject(repo.path);
|
||||||
|
|
||||||
|
// Create workspace via daemon API since the new workspace screen
|
||||||
|
// no longer has a standalone terminal button
|
||||||
|
const worktreeSlug = `setup-terminal-${Date.now()}`;
|
||||||
|
const result = await client.createPaseoWorktree({
|
||||||
|
cwd: repo.path,
|
||||||
|
worktreeSlug,
|
||||||
|
});
|
||||||
|
if (!result.workspace || result.error) {
|
||||||
|
throw new Error(result.error ?? "Failed to create workspace");
|
||||||
|
}
|
||||||
|
const workspaceDir = result.workspace.workspaceDirectory;
|
||||||
|
const workspaceId = String(result.workspace.id);
|
||||||
|
|
||||||
|
// Navigate to the worktree workspace via sidebar click (direct URL
|
||||||
|
// navigation for freshly created worktree workspaces can race with
|
||||||
|
// Expo Router hydration, so we use the sidebar which is authoritative).
|
||||||
|
await openHomeWithProject(page, repo.path);
|
||||||
|
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||||
|
|
||||||
|
await clickTerminal(page);
|
||||||
|
|
||||||
|
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||||
|
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
// Verify terminal is listed under the worktree directory, not the original repo
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await client.listTerminals(workspaceDir)).terminals.length > 0, {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
|
expect((await client.listTerminals(repo.path)).terminals.length).toBe(0);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
|
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||||
|
import { waitForTerminalContent } from "./helpers/terminal-perf";
|
||||||
|
import {
|
||||||
|
connectWorkspaceSetupClient,
|
||||||
|
createWorkspaceThroughDaemon,
|
||||||
|
expectSetupPanel,
|
||||||
|
openHomeWithProject,
|
||||||
|
seedProjectForWorkspaceSetup,
|
||||||
|
waitForWorkspaceSetupProgress,
|
||||||
|
} from "./helpers/workspace-setup";
|
||||||
|
|
||||||
|
function getServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Click the sidebar row for a workspace (by ID) and wait for navigation. */
|
||||||
|
async function navigateToWorkspaceViaSidebar(
|
||||||
|
page: import("@playwright/test").Page,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||||
|
const row = page.getByTestId(testId);
|
||||||
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
|
await row.click();
|
||||||
|
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Workspace setup streaming", () => {
|
||||||
|
test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => {
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("setup-open-", {
|
||||||
|
paseoConfig: {
|
||||||
|
worktree: {
|
||||||
|
setup: [
|
||||||
|
"sh -c 'echo starting setup; for i in $(seq 1 30); do echo tick $i; sleep 1; done; echo setup complete'",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||||
|
cwd: repo.path,
|
||||||
|
worktreeSlug: `setup-open-${Date.now()}`,
|
||||||
|
});
|
||||||
|
await openHomeWithProject(page, repo.path);
|
||||||
|
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||||
|
|
||||||
|
await expectSetupPanel(page);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runs setup through the sidebar and leaves the workspace usable", async ({ page }) => {
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("setup-ui-flow-", {
|
||||||
|
paseoConfig: {
|
||||||
|
worktree: {
|
||||||
|
setup: [
|
||||||
|
"sh -c 'echo starting setup; sleep 1; echo loading dependencies; sleep 1; echo setup complete'",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
files: [{ path: "src/index.ts", content: "export const ready = true;\n" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
|
||||||
|
// Wait for setup completion via daemon (setup snapshots are per-session,
|
||||||
|
// so the browser session won't receive progress events).
|
||||||
|
const completed = waitForWorkspaceSetupProgress(
|
||||||
|
client,
|
||||||
|
(payload) =>
|
||||||
|
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||||
|
);
|
||||||
|
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||||
|
cwd: repo.path,
|
||||||
|
worktreeSlug: `setup-ui-flow-${Date.now()}`,
|
||||||
|
});
|
||||||
|
await completed;
|
||||||
|
|
||||||
|
// Navigate to workspace and verify it's usable
|
||||||
|
await openHomeWithProject(page, repo.path);
|
||||||
|
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||||
|
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first().click();
|
||||||
|
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||||
|
if ((await explorerToggle.getAttribute("aria-label")) === "Open explorer") {
|
||||||
|
await explorerToggle.click();
|
||||||
|
}
|
||||||
|
await expect(explorerToggle).toHaveAttribute("aria-label", "Close explorer", {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await page.getByTestId("explorer-tab-files").click();
|
||||||
|
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(page.getByText("README.md", { exact: true }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expect(page.getByText("src", { exact: true }).first()).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("streams running and completed setup snapshots for a successful setup", async () => {
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("setup-success-", {
|
||||||
|
paseoConfig: {
|
||||||
|
worktree: {
|
||||||
|
setup: ["sh -c 'echo starting setup; sleep 2; echo setup complete'"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
const initialRunning = waitForWorkspaceSetupProgress(
|
||||||
|
client,
|
||||||
|
(payload) => payload.status === "running" && payload.detail.log === "",
|
||||||
|
);
|
||||||
|
const runningWithOutput = waitForWorkspaceSetupProgress(
|
||||||
|
client,
|
||||||
|
(payload) => payload.status === "running" && payload.detail.log.includes("starting setup"),
|
||||||
|
);
|
||||||
|
const completed = waitForWorkspaceSetupProgress(
|
||||||
|
client,
|
||||||
|
(payload) =>
|
||||||
|
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await createWorkspaceThroughDaemon(client, {
|
||||||
|
cwd: repo.path,
|
||||||
|
worktreeSlug: "workspace-setup-success",
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialPayload = await initialRunning;
|
||||||
|
const runningPayload = await runningWithOutput;
|
||||||
|
const completedPayload = await completed;
|
||||||
|
|
||||||
|
expect(initialPayload.detail.log).toBe("");
|
||||||
|
expect(runningPayload.detail.log).toContain("starting setup");
|
||||||
|
expect(completedPayload.detail.log).toContain("setup complete");
|
||||||
|
expect(completedPayload.error).toBeNull();
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("streams a failed setup snapshot when setup fails", async () => {
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("setup-failure-", {
|
||||||
|
paseoConfig: {
|
||||||
|
worktree: {
|
||||||
|
setup: ["sh -c 'echo starting setup; sleep 2; echo setup failed 1>&2; exit 1'"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
const failed = waitForWorkspaceSetupProgress(
|
||||||
|
client,
|
||||||
|
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
|
||||||
|
);
|
||||||
|
|
||||||
|
await createWorkspaceThroughDaemon(client, {
|
||||||
|
cwd: repo.path,
|
||||||
|
worktreeSlug: "workspace-setup-failure",
|
||||||
|
});
|
||||||
|
|
||||||
|
const failedPayload = await failed;
|
||||||
|
expect(failedPayload.detail.log).toContain("starting setup");
|
||||||
|
expect(failedPayload.detail.log).toContain("setup failed");
|
||||||
|
expect(failedPayload.error).toMatch(/failed/i);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emits a completed empty snapshot when no setup commands exist", async () => {
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("setup-none-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
const completed = waitForWorkspaceSetupProgress(
|
||||||
|
client,
|
||||||
|
(payload) =>
|
||||||
|
payload.status === "completed" &&
|
||||||
|
payload.detail.commands.length === 0 &&
|
||||||
|
payload.detail.log === "",
|
||||||
|
);
|
||||||
|
|
||||||
|
await createWorkspaceThroughDaemon(client, {
|
||||||
|
cwd: repo.path,
|
||||||
|
worktreeSlug: "workspace-setup-none",
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedPayload = await completed;
|
||||||
|
expect(completedPayload.error).toBeNull();
|
||||||
|
expect(completedPayload.detail.commands).toEqual([]);
|
||||||
|
expect(completedPayload.detail.log).toBe("");
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("launches script terminals after setup completes", async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("setup-svc-ui-", {
|
||||||
|
paseoConfig: {
|
||||||
|
worktree: {
|
||||||
|
setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"],
|
||||||
|
},
|
||||||
|
scripts: {
|
||||||
|
web: {
|
||||||
|
command:
|
||||||
|
"node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, () => console.log('listening on ' + s.address().port))\"",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
|
||||||
|
// Wait for setup completion via daemon (setup snapshots are per-session)
|
||||||
|
const completed = waitForWorkspaceSetupProgress(
|
||||||
|
client,
|
||||||
|
(payload) =>
|
||||||
|
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||||
|
);
|
||||||
|
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||||
|
cwd: repo.path,
|
||||||
|
worktreeSlug: `setup-svc-${Date.now()}`,
|
||||||
|
});
|
||||||
|
await completed;
|
||||||
|
|
||||||
|
await openHomeWithProject(page, repo.path);
|
||||||
|
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||||
|
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
|
||||||
|
// Wait for the script terminal tab to appear in the tabs bar.
|
||||||
|
// The tab title shows the command, not the script name.
|
||||||
|
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
|
||||||
|
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// Click the script terminal tab
|
||||||
|
await terminalTab.click();
|
||||||
|
|
||||||
|
// Verify the terminal surface rendered
|
||||||
|
const terminalSurface = page.getByTestId("terminal-surface").first();
|
||||||
|
await expect(terminalSurface).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// Wait for terminal to fully attach (loading overlay gone)
|
||||||
|
await page
|
||||||
|
.locator('[data-testid="terminal-attach-loading"]')
|
||||||
|
.waitFor({ state: "hidden", timeout: 10_000 })
|
||||||
|
.catch(() => {
|
||||||
|
// overlay may never appear if attachment is instant
|
||||||
|
});
|
||||||
|
|
||||||
|
await terminalSurface.click();
|
||||||
|
|
||||||
|
// Verify the terminal output contains "listening on" via xterm buffer API.
|
||||||
|
// The .xterm-rows CSS selector is fragile; reading the buffer directly is reliable.
|
||||||
|
await waitForTerminalContent(page, (text) => text.includes("listening on"), 60_000);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("launches workspace scripts after setup completes", async () => {
|
||||||
|
const client = await connectWorkspaceSetupClient();
|
||||||
|
const repo = await createTempGitRepo("setup-scripts-", {
|
||||||
|
paseoConfig: {
|
||||||
|
worktree: {
|
||||||
|
setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"],
|
||||||
|
},
|
||||||
|
scripts: {
|
||||||
|
editor: {
|
||||||
|
command: "npm run dev",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
const completed = waitForWorkspaceSetupProgress(
|
||||||
|
client,
|
||||||
|
(payload) =>
|
||||||
|
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.createPaseoWorktree({
|
||||||
|
cwd: repo.path,
|
||||||
|
worktreeSlug: "workspace-setup-scripts",
|
||||||
|
});
|
||||||
|
if (!result.workspace) {
|
||||||
|
throw new Error(result.error ?? "Failed to create workspace");
|
||||||
|
}
|
||||||
|
const workspaceDir = result.workspace.workspaceDirectory;
|
||||||
|
|
||||||
|
await completed;
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const terminals = await client.listTerminals(workspaceDir);
|
||||||
|
return terminals.terminals.find((terminal) => terminal.name === "editor") ?? null;
|
||||||
|
})
|
||||||
|
.toMatchObject({
|
||||||
|
id: expect.any(String),
|
||||||
|
name: "editor",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# NOTE: Maestro requires a config section for every flow file, even when used
|
||||||
|
# via `runFlow`. Keep this aligned with the dev build application id.
|
||||||
|
appId: sh.paseo
|
||||||
|
---
|
||||||
|
- launchApp:
|
||||||
|
clearState: true
|
||||||
|
- runFlow: launch.yaml
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "welcome-screen"
|
||||||
|
timeout: 30000
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "welcome-direct-connection"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "add-host-modal"
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "direct-host-input"
|
||||||
|
- eraseText
|
||||||
|
- inputText: "127.0.0.1:6767"
|
||||||
|
|
||||||
|
- assertVisible:
|
||||||
|
id: "direct-host-input"
|
||||||
|
text: "127.0.0.1:6767"
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "direct-host-submit"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "message-input-root"
|
||||||
|
timeout: 60000
|
||||||
|
|
||||||
|
- assertVisible:
|
||||||
|
id: "message-input-attach-button"
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
appId: sh.paseo
|
||||||
|
---
|
||||||
|
- runFlow: flows/land-in-chat.yaml
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "message-input-attach-button"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "message-input-attachment-menu"
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "message-input-attachment-menu-item-image"
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Allow Full Access"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Allow Full Access"
|
||||||
|
|
||||||
|
- runFlow:
|
||||||
|
when:
|
||||||
|
visible: "Allow Access to All Photos"
|
||||||
|
commands:
|
||||||
|
- tapOn: "Allow Access to All Photos"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Cancel"
|
||||||
|
timeout: 30000
|
||||||
|
|
||||||
|
- tapOn: "Cancel"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "message-input-root"
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
# If the dropdown Modal left an invisible backdrop mounted behind the picker,
|
||||||
|
# this tap is swallowed and the attachment menu never reopens.
|
||||||
|
- tapOn:
|
||||||
|
id: "message-input-attach-button"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "message-input-attachment-menu"
|
||||||
|
timeout: 10000
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "message-input-attachment-menu-backdrop"
|
||||||
@@ -8,7 +8,15 @@ appId: sh.paseo
|
|||||||
|
|
||||||
- eraseText
|
- eraseText
|
||||||
- inputText: ${PAIRING_OFFER_URL}
|
- inputText: ${PAIRING_OFFER_URL}
|
||||||
- tapOn: "Pair"
|
|
||||||
|
# Prove the input received the URL intact. If this fails, iOS keyboard input
|
||||||
|
# dropped characters and the controlled input path needs a source fix.
|
||||||
|
- assertVisible:
|
||||||
|
id: "pair-link-input"
|
||||||
|
text: ${PAIRING_OFFER_URL}
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
id: "pair-link-submit"
|
||||||
|
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "New agent"
|
visible: "New agent"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ appId: sh.paseo
|
|||||||
- eraseText
|
- eraseText
|
||||||
- inputText: ${PAIRING_OFFER_URL}
|
- inputText: ${PAIRING_OFFER_URL}
|
||||||
- tapOn:
|
- tapOn:
|
||||||
point: "75%,36%"
|
id: "pair-link-submit"
|
||||||
|
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "New agent"
|
visible: "New agent"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"version": "0.1.58",
|
"version": "0.1.60-beta.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "APP_VARIANT=development expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||||
"eas-build-post-install": "npm run build:workspace-deps",
|
"eas-build-post-install": "npm run build:workspace-deps",
|
||||||
@@ -31,9 +31,9 @@
|
|||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@floating-ui/react-native": "^0.10.7",
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@getpaseo/expo-two-way-audio": "0.1.58",
|
"@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
|
||||||
"@getpaseo/highlight": "0.1.58",
|
"@getpaseo/highlight": "0.1.60-beta.1",
|
||||||
"@getpaseo/server": "0.1.58",
|
"@getpaseo/server": "0.1.60-beta.1",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
"@gorhom/portal": "^1.0.14",
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
@@ -79,10 +79,11 @@
|
|||||||
"expo-system-ui": "~6.0.7",
|
"expo-system-ui": "~6.0.7",
|
||||||
"expo-updates": "~29.0.12",
|
"expo-updates": "~29.0.12",
|
||||||
"expo-web-browser": "~15.0.8",
|
"expo-web-browser": "~15.0.8",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
"lucide-react-native": "^0.546.0",
|
"lucide-react-native": "^0.546.0",
|
||||||
"mnemonic-id": "^3.2.7",
|
"mnemonic-id": "^3.2.7",
|
||||||
"react": "19.1.4",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.4",
|
"react-dom": "19.1.0",
|
||||||
"react-native": "^0.81.5",
|
"react-native": "^0.81.5",
|
||||||
"react-native-css": "^3.0.1",
|
"react-native-css": "^3.0.1",
|
||||||
"react-native-draggable-flatlist": "^4.0.3",
|
"react-native-draggable-flatlist": "^4.0.3",
|
||||||
@@ -102,11 +103,13 @@
|
|||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-webview": "^13.16.0",
|
"react-native-webview": "^13.16.0",
|
||||||
"react-native-worklets": "0.5.1",
|
"react-native-worklets": "0.5.1",
|
||||||
|
"use-sync-external-store": "^1.6.0",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.56.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"eas-cli": "^16.24.1",
|
"eas-cli": "^16.24.1",
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||||
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
|
||||||
import { PortalProvider } from "@gorhom/portal";
|
import { PortalProvider } from "@gorhom/portal";
|
||||||
import { VoiceProvider } from "@/contexts/voice-context";
|
import { VoiceProvider } from "@/contexts/voice-context";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
@@ -62,8 +61,14 @@ import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout
|
|||||||
import { CommandCenter } from "@/components/command-center";
|
import { CommandCenter } from "@/components/command-center";
|
||||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||||
|
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||||
|
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||||
|
import { resolveActiveHost } from "@/utils/active-host";
|
||||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
|
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
||||||
|
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { queryClient } from "@/query/query-client";
|
import { queryClient } from "@/query/query-client";
|
||||||
|
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||||
import {
|
import {
|
||||||
WEB_NOTIFICATION_CLICK_EVENT,
|
WEB_NOTIFICATION_CLICK_EVENT,
|
||||||
type WebNotificationClickDetail,
|
type WebNotificationClickDetail,
|
||||||
@@ -79,8 +84,12 @@ import {
|
|||||||
parseServerIdFromPathname,
|
parseServerIdFromPathname,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseWorkspaceOpenIntent,
|
parseWorkspaceOpenIntent,
|
||||||
|
decodeWorkspaceIdFromPathSegment,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
import {
|
||||||
|
addBrowserActiveWorkspaceLocationListener,
|
||||||
|
syncNavigationActiveWorkspace,
|
||||||
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
import { isWeb, isNative } from "@/constants/platform";
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
|
||||||
polyfillCrypto();
|
polyfillCrypto();
|
||||||
@@ -385,9 +394,11 @@ function AppContainer({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { settings, updateSettings } = useAppSettings();
|
const { settings, updateSettings } = useAppSettings();
|
||||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
||||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
|
||||||
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
const openDesktopAgentList = usePanelStore((state) => state.openDesktopAgentList);
|
||||||
|
const closeDesktopAgentList = usePanelStore((state) => state.closeDesktopAgentList);
|
||||||
|
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||||
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
||||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||||
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||||
@@ -401,6 +412,32 @@ function AppContainer({
|
|||||||
|
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||||
|
const pathname = usePathname();
|
||||||
|
const activeServerId = useMemo(
|
||||||
|
() => resolveActiveHost({ hosts: daemons, pathname })?.serverId ?? null,
|
||||||
|
[daemons, pathname],
|
||||||
|
);
|
||||||
|
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
|
||||||
|
const toggleDesktopSidebars = useCallback(() => {
|
||||||
|
const { desktop } = usePanelStore.getState();
|
||||||
|
toggleDesktopSidebarsWithCheckoutIntent({
|
||||||
|
isAgentListOpen: desktop.agentListOpen,
|
||||||
|
isFileExplorerOpen: desktop.fileExplorerOpen,
|
||||||
|
openAgentList: openDesktopAgentList,
|
||||||
|
closeAgentList: closeDesktopAgentList,
|
||||||
|
closeFileExplorer: closeDesktopFileExplorer,
|
||||||
|
toggleFocusedFileExplorer: () =>
|
||||||
|
keyboardActionDispatcher.dispatch({
|
||||||
|
id: "sidebar.toggle.right",
|
||||||
|
scope: "sidebar",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}, [closeDesktopAgentList, closeDesktopFileExplorer, openDesktopAgentList]);
|
||||||
|
// TODO: stop matching pathname here as a branch. `chromeEnabled` should not
|
||||||
|
// conflate workspace/project-specific chrome (sidebar, mobile gesture) with
|
||||||
|
// global concerns like keyboard shortcuts. Split those out so settings (and
|
||||||
|
// other non-workspace routes) don't need a special-case to keep shortcuts alive.
|
||||||
|
const keyboardShortcutsEnabled = chromeEnabled || pathname.startsWith("/settings");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const bp = UnistylesRuntime.breakpoint;
|
const bp = UnistylesRuntime.breakpoint;
|
||||||
@@ -432,16 +469,16 @@ function AppContainer({
|
|||||||
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
|
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
|
||||||
|
|
||||||
useKeyboardShortcuts({
|
useKeyboardShortcuts({
|
||||||
enabled: chromeEnabled,
|
enabled: keyboardShortcutsEnabled,
|
||||||
isMobile: isCompactLayout,
|
isMobile: isCompactLayout,
|
||||||
toggleAgentList,
|
toggleAgentList,
|
||||||
selectedAgentId,
|
toggleBothSidebars: toggleDesktopSidebars,
|
||||||
toggleFileExplorer,
|
|
||||||
toggleBothSidebars,
|
|
||||||
toggleFocusMode,
|
toggleFocusMode,
|
||||||
cycleTheme,
|
cycleTheme,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useActiveWorktreeNewAction();
|
||||||
|
|
||||||
const containerStyle = useMemo(
|
const containerStyle = useMemo(
|
||||||
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
|
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
|
||||||
[theme.colors.surface0],
|
[theme.colors.surface0],
|
||||||
@@ -460,6 +497,11 @@ function AppContainer({
|
|||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<CommandCenter />
|
<CommandCenter />
|
||||||
<ProjectPickerModal />
|
<ProjectPickerModal />
|
||||||
|
<WorkspaceShortcutTargetsSubscriber
|
||||||
|
enabled={keyboardShortcutsEnabled}
|
||||||
|
serverId={activeServerId}
|
||||||
|
/>
|
||||||
|
<WorkspaceSetupDialog />
|
||||||
<KeyboardShortcutsDialog />
|
<KeyboardShortcutsDialog />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -479,7 +521,7 @@ function MobileGestureWrapper({
|
|||||||
chromeEnabled: boolean;
|
chromeEnabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
||||||
const horizontalScroll = useHorizontalScrollOptional();
|
const horizontalScroll = useHorizontalScrollOptional();
|
||||||
const {
|
const {
|
||||||
translateX,
|
translateX,
|
||||||
@@ -496,8 +538,8 @@ function MobileGestureWrapper({
|
|||||||
|
|
||||||
const handleGestureOpen = useCallback(() => {
|
const handleGestureOpen = useCallback(() => {
|
||||||
gestureAnimatingRef.current = true;
|
gestureAnimatingRef.current = true;
|
||||||
openAgentList();
|
showMobileAgentList();
|
||||||
}, [openAgentList, gestureAnimatingRef]);
|
}, [showMobileAgentList, gestureAnimatingRef]);
|
||||||
|
|
||||||
const openGesture = useMemo(
|
const openGesture = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -788,7 +830,6 @@ function FaviconStatusSync() {
|
|||||||
function RootStack() {
|
function RootStack() {
|
||||||
const storeReady = useStoreReady();
|
const storeReady = useStoreReady();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
@@ -801,23 +842,25 @@ function RootStack() {
|
|||||||
>
|
>
|
||||||
<Stack.Protected guard={storeReady}>
|
<Stack.Protected guard={storeReady}>
|
||||||
<Stack.Screen name="welcome" />
|
<Stack.Screen name="welcome" />
|
||||||
<Stack.Screen name="settings" />
|
<Stack.Screen name="settings/index" />
|
||||||
|
<Stack.Screen name="settings/[section]" />
|
||||||
<Stack.Screen name="pair-scan" />
|
<Stack.Screen name="pair-scan" />
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
<Stack.Screen
|
{/*
|
||||||
name="h/[serverId]/workspace/[workspaceId]"
|
Do not add getId or dangerouslySingular back to the workspace route.
|
||||||
getId={({ params }) => {
|
Expo Router maps dangerouslySingular to React Navigation getId, and
|
||||||
const serverId = getRouteParamValue(params?.serverId);
|
getId repeatedly breaks Android native-stack/Fabric by reordering an
|
||||||
const workspaceId = getRouteParamValue(params?.workspaceId);
|
already-mounted workspace screen. Keep workspace identity/retention
|
||||||
return serverId && workspaceId ? `${serverId}:${workspaceId}` : undefined;
|
outside this route-level native-stack API.
|
||||||
}}
|
*/}
|
||||||
/>
|
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||||
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
|
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
|
||||||
<Stack.Screen name="h/[serverId]/index" />
|
<Stack.Screen name="h/[serverId]/index" />
|
||||||
<Stack.Screen name="h/[serverId]/sessions" />
|
<Stack.Screen name="h/[serverId]/sessions" />
|
||||||
<Stack.Screen name="h/[serverId]/open-project" />
|
<Stack.Screen name="h/[serverId]/open-project" />
|
||||||
<Stack.Screen name="h/[serverId]/settings" />
|
<Stack.Screen name="h/[serverId]/settings" />
|
||||||
<Stack.Screen name="index" />
|
<Stack.Screen name="index" />
|
||||||
|
<Stack.Screen name="settings/hosts/[serverId]" />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -827,6 +870,7 @@ function NavigationActiveWorkspaceObserver() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
syncNavigationActiveWorkspace(navigationRef);
|
syncNavigationActiveWorkspace(navigationRef);
|
||||||
|
const unsubscribeBrowserLocation = addBrowserActiveWorkspaceLocationListener();
|
||||||
const unsubscribeState = navigationRef.addListener("state", () => {
|
const unsubscribeState = navigationRef.addListener("state", () => {
|
||||||
syncNavigationActiveWorkspace(navigationRef);
|
syncNavigationActiveWorkspace(navigationRef);
|
||||||
});
|
});
|
||||||
@@ -834,6 +878,7 @@ function NavigationActiveWorkspaceObserver() {
|
|||||||
syncNavigationActiveWorkspace(navigationRef);
|
syncNavigationActiveWorkspace(navigationRef);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
|
unsubscribeBrowserLocation();
|
||||||
unsubscribeState();
|
unsubscribeState();
|
||||||
unsubscribeReady();
|
unsubscribeReady();
|
||||||
};
|
};
|
||||||
@@ -852,23 +897,21 @@ export default function RootLayout() {
|
|||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<KeyboardProvider>
|
<KeyboardProvider>
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<BottomSheetModalProvider>
|
<HostRuntimeBootstrapProvider>
|
||||||
<HostRuntimeBootstrapProvider>
|
<PushNotificationRouter />
|
||||||
<PushNotificationRouter />
|
<ToastProvider>
|
||||||
<ProvidersWrapper>
|
<ProvidersWrapper>
|
||||||
<SidebarAnimationProvider>
|
<SidebarAnimationProvider>
|
||||||
<HorizontalScrollProvider>
|
<HorizontalScrollProvider>
|
||||||
<ToastProvider>
|
<OpenProjectListener />
|
||||||
<OpenProjectListener />
|
<AppWithSidebar>
|
||||||
<AppWithSidebar>
|
<RootStack />
|
||||||
<RootStack />
|
</AppWithSidebar>
|
||||||
</AppWithSidebar>
|
|
||||||
</ToastProvider>
|
|
||||||
</HorizontalScrollProvider>
|
</HorizontalScrollProvider>
|
||||||
</SidebarAnimationProvider>
|
</SidebarAnimationProvider>
|
||||||
</ProvidersWrapper>
|
</ProvidersWrapper>
|
||||||
</HostRuntimeBootstrapProvider>
|
</ToastProvider>
|
||||||
</BottomSheetModalProvider>
|
</HostRuntimeBootstrapProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</KeyboardProvider>
|
</KeyboardProvider>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { useEffect, useRef } from "react";
|
|||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
|
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
|
||||||
export default function HostAgentReadyRoute() {
|
export default function HostAgentReadyRoute() {
|
||||||
@@ -31,6 +33,10 @@ function HostAgentReadyRouteContent() {
|
|||||||
}
|
}
|
||||||
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
|
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
|
||||||
});
|
});
|
||||||
|
const hasHydratedWorkspaces = useSessionStore((state) =>
|
||||||
|
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
||||||
|
);
|
||||||
|
const resolvedWorkspaceId = useResolveWorkspaceIdByCwd(serverId, agentCwd);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (redirectedRef.current) {
|
if (redirectedRef.current) {
|
||||||
@@ -42,18 +48,17 @@ function HostAgentReadyRouteContent() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedCwd = agentCwd?.trim();
|
if (resolvedWorkspaceId) {
|
||||||
if (normalizedCwd) {
|
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
router.replace(
|
router.replace(
|
||||||
prepareWorkspaceTab({
|
prepareWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId: normalizedCwd,
|
workspaceId: resolvedWorkspaceId,
|
||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
}) as any,
|
}) as any,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [agentCwd, agentId, router, serverId]);
|
}, [agentId, resolvedWorkspaceId, router, serverId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (redirectedRef.current) {
|
if (redirectedRef.current) {
|
||||||
@@ -62,14 +67,14 @@ function HostAgentReadyRouteContent() {
|
|||||||
if (!serverId || !agentId) {
|
if (!serverId || !agentId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (agentCwd?.trim()) {
|
if (agentCwd?.trim() && !hasHydratedWorkspaces) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!client || !isConnected) {
|
if (!client || !isConnected) {
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
router.replace(buildHostRootRoute(serverId));
|
router.replace(buildHostRootRoute(serverId));
|
||||||
}
|
}
|
||||||
}, [agentCwd, agentId, client, isConnected, router, serverId]);
|
}, [agentCwd, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (redirectedRef.current) {
|
if (redirectedRef.current) {
|
||||||
@@ -87,12 +92,20 @@ function HostAgentReadyRouteContent() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cwd = result?.agent?.cwd?.trim();
|
const cwd = result?.agent?.cwd?.trim();
|
||||||
|
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||||
|
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||||
|
workspaces: workspaces?.values(),
|
||||||
|
workspaceDirectory: cwd,
|
||||||
|
});
|
||||||
|
if (!workspaceId && !hasHydratedWorkspaces) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
if (cwd) {
|
if (workspaceId) {
|
||||||
router.replace(
|
router.replace(
|
||||||
prepareWorkspaceTab({
|
prepareWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId: cwd,
|
workspaceId,
|
||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
}) as any,
|
}) as any,
|
||||||
);
|
);
|
||||||
@@ -111,7 +124,7 @@ function HostAgentReadyRouteContent() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [agentId, client, isConnected, router, serverId]);
|
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +1,9 @@
|
|||||||
import { useEffect } from "react";
|
import { Redirect, useLocalSearchParams } from "expo-router";
|
||||||
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
import { buildHostOpenProjectRoute } from "@/utils/host-routes";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
|
||||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
|
||||||
import {
|
|
||||||
buildHostOpenProjectRoute,
|
|
||||||
buildHostRootRoute,
|
|
||||||
buildHostWorkspaceRoute,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
|
||||||
|
|
||||||
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
|
|
||||||
|
|
||||||
export default function HostIndexRoute() {
|
export default function HostIndexRoute() {
|
||||||
return (
|
|
||||||
<HostRouteBootstrapBoundary>
|
|
||||||
<HostIndexRouteContent />
|
|
||||||
</HostRouteBootstrapBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HostIndexRouteContent() {
|
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||||
const { isLoading: preferencesLoading } = useFormPreferences();
|
if (!serverId) return null;
|
||||||
const sessionAgents = useSessionStore((state) =>
|
return <Redirect href={buildHostOpenProjectRoute(serverId)} />;
|
||||||
serverId ? state.sessions[serverId]?.agents : undefined,
|
|
||||||
);
|
|
||||||
const sessionWorkspaces = useSessionStore((state) =>
|
|
||||||
serverId ? state.sessions[serverId]?.workspaces : undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (preferencesLoading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!serverId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const rootRoute = buildHostRootRoute(serverId);
|
|
||||||
if (pathname !== rootRoute && pathname !== `${rootRoute}/`) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
if (pathname !== rootRoute && pathname !== `${rootRoute}/`) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleAgents = sessionAgents
|
|
||||||
? Array.from(sessionAgents.values()).filter((agent) => !agent.archivedAt)
|
|
||||||
: [];
|
|
||||||
visibleAgents.sort(
|
|
||||||
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const visibleWorkspaces = sessionWorkspaces ? Array.from(sessionWorkspaces.values()) : [];
|
|
||||||
|
|
||||||
const primaryAgent = visibleAgents[0];
|
|
||||||
if (primaryAgent?.cwd?.trim()) {
|
|
||||||
router.replace(
|
|
||||||
prepareWorkspaceTab({
|
|
||||||
serverId,
|
|
||||||
workspaceId: primaryAgent.cwd.trim(),
|
|
||||||
target: { kind: "agent", agentId: primaryAgent.id },
|
|
||||||
}) as any,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const primaryWorkspace = visibleWorkspaces[0];
|
|
||||||
if (primaryWorkspace?.id?.trim()) {
|
|
||||||
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.replace(buildHostOpenProjectRoute(serverId));
|
|
||||||
}, HOST_ROOT_REDIRECT_DELAY_MS);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [pathname, preferencesLoading, router, serverId, sessionAgents, sessionWorkspaces]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
|
||||||
|
|
||||||
|
export default function HostNewWorkspaceRoute() {
|
||||||
|
const params = useLocalSearchParams<{ serverId?: string; dir?: string; name?: string }>();
|
||||||
|
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||||
|
const sourceDirectory = typeof params.dir === "string" ? params.dir : "";
|
||||||
|
const displayName = typeof params.name === "string" ? params.name : undefined;
|
||||||
|
|
||||||
|
if (!sourceDirectory) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NewWorkspaceScreen
|
||||||
|
serverId={serverId}
|
||||||
|
sourceDirectory={sourceDirectory}
|
||||||
|
displayName={displayName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
|
import { Redirect, useLocalSearchParams } from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
import SettingsScreen from "@/screens/settings-screen";
|
import { buildSettingsHostRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
|
export default function LegacyHostSettingsRoute() {
|
||||||
|
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||||
|
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||||
|
const href = serverId.length > 0 ? buildSettingsHostRoute(serverId) : buildSettingsRoute();
|
||||||
|
|
||||||
export default function HostSettingsRoute() {
|
|
||||||
return (
|
return (
|
||||||
<HostRouteBootstrapBoundary>
|
<HostRouteBootstrapBoundary>
|
||||||
<SettingsScreen />
|
<Redirect href={href} />
|
||||||
</HostRouteBootstrapBoundary>
|
</HostRouteBootstrapBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import { StyleSheet, View } from "react-native";
|
||||||
|
import {
|
||||||
|
useGlobalSearchParams,
|
||||||
|
useLocalSearchParams,
|
||||||
|
useRouter,
|
||||||
|
useRootNavigationState,
|
||||||
|
} from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
|
import {
|
||||||
|
activateNavigationWorkspaceSelection,
|
||||||
|
type ActiveWorkspaceSelection,
|
||||||
|
useNavigationActiveWorkspaceSelection,
|
||||||
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||||
import {
|
import {
|
||||||
|
buildHostWorkspaceRoute,
|
||||||
decodeWorkspaceIdFromPathSegment,
|
decodeWorkspaceIdFromPathSegment,
|
||||||
parseWorkspaceOpenIntent,
|
parseWorkspaceOpenIntent,
|
||||||
type WorkspaceOpenIntent,
|
type WorkspaceOpenIntent,
|
||||||
@@ -32,9 +45,35 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
|
|||||||
if (openIntent.kind === "file") {
|
if (openIntent.kind === "file") {
|
||||||
return { kind: "file", path: openIntent.path };
|
return { kind: "file", path: openIntent.path };
|
||||||
}
|
}
|
||||||
|
if (openIntent.kind === "setup") {
|
||||||
|
return { kind: "setup", workspaceId: openIntent.workspaceId };
|
||||||
|
}
|
||||||
return { kind: "draft", draftId: openIntent.draftId };
|
return { kind: "draft", draftId: openIntent.draftId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripOpenSearchParamFromBrowserUrl() {
|
||||||
|
if (!isWeb || typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (!url.searchParams.has("open")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
url.searchParams.delete("open");
|
||||||
|
window.history.replaceState(null, "", url.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearConsumedOpenIntent(input: {
|
||||||
|
navigation: { setParams: (...args: any[]) => void };
|
||||||
|
router: { replace: (...args: any[]) => void };
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}) {
|
||||||
|
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
|
||||||
|
input.navigation.setParams({ open: undefined });
|
||||||
|
stripOpenSearchParamFromBrowserUrl();
|
||||||
|
}
|
||||||
|
|
||||||
export default function HostWorkspaceLayout() {
|
export default function HostWorkspaceLayout() {
|
||||||
return (
|
return (
|
||||||
<HostRouteBootstrapBoundary>
|
<HostRouteBootstrapBoundary>
|
||||||
@@ -44,6 +83,8 @@ export default function HostWorkspaceLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HostWorkspaceLayoutContent() {
|
function HostWorkspaceLayoutContent() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const router = useRouter();
|
||||||
const rootNavigationState = useRootNavigationState();
|
const rootNavigationState = useRootNavigationState();
|
||||||
const consumedIntentRef = useRef<string | null>(null);
|
const consumedIntentRef = useRef<string | null>(null);
|
||||||
const [intentConsumed, setIntentConsumed] = useState(false);
|
const [intentConsumed, setIntentConsumed] = useState(false);
|
||||||
@@ -60,6 +101,23 @@ function HostWorkspaceLayoutContent() {
|
|||||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||||
: "";
|
: "";
|
||||||
const openValue = getParamValue(globalParams.open);
|
const openValue = getParamValue(globalParams.open);
|
||||||
|
const routeWorkspaceSelection = useMemo(
|
||||||
|
() =>
|
||||||
|
serverId && workspaceId
|
||||||
|
? {
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
[serverId, workspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!routeWorkspaceSelection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activateNavigationWorkspaceSelection(routeWorkspaceSelection);
|
||||||
|
}, [routeWorkspaceSelection]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!openValue) {
|
if (!openValue) {
|
||||||
@@ -71,6 +129,13 @@ function HostWorkspaceLayoutContent() {
|
|||||||
|
|
||||||
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
||||||
if (consumedIntentRef.current === consumptionKey) {
|
if (consumedIntentRef.current === consumptionKey) {
|
||||||
|
clearConsumedOpenIntent({
|
||||||
|
navigation,
|
||||||
|
router,
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
setIntentConsumed(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
consumedIntentRef.current = consumptionKey;
|
consumedIntentRef.current = consumptionKey;
|
||||||
@@ -88,26 +153,87 @@ function HostWorkspaceLayoutContent() {
|
|||||||
// Expo Router's replace ignores query-param-only changes (findDivergentState
|
// Expo Router's replace ignores query-param-only changes (findDivergentState
|
||||||
// skips search params). Strip ?open from the browser URL directly so the
|
// skips search params). Strip ?open from the browser URL directly so the
|
||||||
// address bar reflects the clean workspace route.
|
// address bar reflects the clean workspace route.
|
||||||
if (isWeb && typeof window !== "undefined") {
|
clearConsumedOpenIntent({
|
||||||
const url = new URL(window.location.href);
|
navigation,
|
||||||
if (url.searchParams.has("open")) {
|
router,
|
||||||
url.searchParams.delete("open");
|
serverId,
|
||||||
window.history.replaceState(null, "", url.toString());
|
workspaceId,
|
||||||
}
|
});
|
||||||
}
|
|
||||||
|
|
||||||
setIntentConsumed(true);
|
setIntentConsumed(true);
|
||||||
}, [openValue, rootNavigationState?.key, serverId, workspaceId]);
|
}, [navigation, openValue, rootNavigationState?.key, router, serverId, workspaceId]);
|
||||||
|
|
||||||
if (openValue && !intentConsumed) {
|
if (openValue && !intentConsumed) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return <WorkspaceDeck fallbackSelection={routeWorkspaceSelection} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function areWorkspaceSelectionsEqual(
|
||||||
|
left: ActiveWorkspaceSelection | null,
|
||||||
|
right: ActiveWorkspaceSelection | null,
|
||||||
|
): boolean {
|
||||||
|
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceDeck({
|
||||||
|
fallbackSelection,
|
||||||
|
}: {
|
||||||
|
fallbackSelection: ActiveWorkspaceSelection | null;
|
||||||
|
}) {
|
||||||
|
const activeSelection = useNavigationActiveWorkspaceSelection() ?? fallbackSelection;
|
||||||
|
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
||||||
|
activeSelection ? [activeSelection] : [],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeSelection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMountedSelections((current) => {
|
||||||
|
if (current.some((selection) => areWorkspaceSelectionsEqual(selection, activeSelection))) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return [...current, activeSelection];
|
||||||
|
});
|
||||||
|
}, [activeSelection]);
|
||||||
|
|
||||||
|
if (!activeSelection) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceScreen
|
<View style={styles.deck}>
|
||||||
key={`${serverId}:${workspaceId}`}
|
{mountedSelections.map((selection) => {
|
||||||
serverId={serverId}
|
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
|
||||||
workspaceId={workspaceId}
|
return (
|
||||||
/>
|
<View
|
||||||
|
key={`${selection.serverId}:${selection.workspaceId}`}
|
||||||
|
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
|
||||||
|
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
||||||
|
>
|
||||||
|
<WorkspaceScreen
|
||||||
|
serverId={selection.serverId}
|
||||||
|
workspaceId={selection.workspaceId}
|
||||||
|
isRouteFocused={isActive}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
deck: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
activeDeckEntry: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
inactiveDeckEntry: {
|
||||||
|
display: "none",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
|||||||
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
|
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
|
||||||
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
|
|
||||||
const WELCOME_ROUTE = "/welcome";
|
const WELCOME_ROUTE = "/welcome";
|
||||||
|
|
||||||
@@ -32,6 +33,8 @@ function useAnyOnlineHostServerId(serverIds: string[]): string | null {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDesktop = shouldUseDesktopDaemon();
|
||||||
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -52,5 +55,5 @@ export default function Index() {
|
|||||||
router.replace(targetRoute);
|
router.replace(targetRoute);
|
||||||
}, [anyOnlineServerId, pathname, router, storeReady]);
|
}, [anyOnlineServerId, pathname, router, storeReady]);
|
||||||
|
|
||||||
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,35 +5,19 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||||
import type { BarcodeScanningResult } from "expo-camera";
|
import type { BarcodeScanningResult } from "expo-camera";
|
||||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
import { useHostMutations } from "@/runtime/host-runtime";
|
||||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||||
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||||
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import { BackHeader } from "@/components/headers/back-header";
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.surface0,
|
||||||
},
|
},
|
||||||
header: {
|
|
||||||
paddingHorizontal: theme.spacing[6],
|
|
||||||
paddingBottom: theme.spacing[4],
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
headerTitle: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.lg,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
},
|
|
||||||
headerButtonText: {
|
|
||||||
color: theme.colors.palette.blue[400],
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
fontWeight: theme.fontWeight.medium,
|
|
||||||
},
|
|
||||||
body: {
|
body: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingHorizontal: theme.spacing[6],
|
paddingHorizontal: theme.spacing[6],
|
||||||
@@ -140,63 +124,32 @@ export default function PairScanScreen() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useLocalSearchParams<{
|
const params = useLocalSearchParams<{
|
||||||
source?: string;
|
source?: string;
|
||||||
sourceServerId?: string;
|
|
||||||
targetServerId?: string;
|
|
||||||
}>();
|
}>();
|
||||||
const source = typeof params.source === "string" ? params.source : "settings";
|
const source = typeof params.source === "string" ? params.source : "settings";
|
||||||
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
|
|
||||||
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
|
|
||||||
const daemons = useHosts();
|
|
||||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||||
|
|
||||||
const [permission, requestPermission] = useCameraPermissions();
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
const [isPairing, setIsPairing] = useState(false);
|
const [isPairing, setIsPairing] = useState(false);
|
||||||
const lastScannedRef = useRef<string | null>(null);
|
const lastScannedRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const returnToSource = useCallback(
|
const navigateToPairedHost = useCallback(
|
||||||
(serverId: string) => {
|
(serverId: string) => {
|
||||||
if (source === "onboarding") {
|
if (source === "onboarding") {
|
||||||
router.replace(buildHostRootRoute(serverId));
|
router.replace(buildHostRootRoute(serverId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (source === "editHost" && targetServerId) {
|
router.replace(buildSettingsHostRoute(serverId));
|
||||||
const settingsServerId = sourceServerId ?? targetServerId;
|
|
||||||
router.replace({
|
|
||||||
pathname: buildHostSettingsRoute(settingsServerId),
|
|
||||||
params: { editHost: targetServerId },
|
|
||||||
} as any);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// settings (default): return to previous screen
|
|
||||||
try {
|
|
||||||
router.back();
|
|
||||||
} catch {
|
|
||||||
const settingsServerId = sourceServerId ?? serverId;
|
|
||||||
router.replace(buildHostSettingsRoute(settingsServerId));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[router, source, sourceServerId, targetServerId],
|
[router, source],
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeToSource = useCallback(() => {
|
const closeToSource = useCallback(() => {
|
||||||
if (source === "editHost" && targetServerId) {
|
|
||||||
const settingsServerId = sourceServerId ?? targetServerId;
|
|
||||||
router.replace({
|
|
||||||
pathname: buildHostSettingsRoute(settingsServerId),
|
|
||||||
params: { editHost: targetServerId },
|
|
||||||
} as any);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
router.back();
|
router.back();
|
||||||
} catch {
|
} catch {
|
||||||
if (sourceServerId) {
|
|
||||||
router.replace(buildHostSettingsRoute(sourceServerId));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.replace("/" as any);
|
router.replace("/" as any);
|
||||||
}
|
}
|
||||||
}, [router, source, sourceServerId, targetServerId]);
|
}, [router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isWeb) return;
|
if (isWeb) return;
|
||||||
@@ -220,15 +173,6 @@ export default function PairScanScreen() {
|
|||||||
const offerPayload = decodeOfferFragmentPayload(encoded);
|
const offerPayload = decodeOfferFragmentPayload(encoded);
|
||||||
const offer = ConnectionOfferSchema.parse(offerPayload);
|
const offer = ConnectionOfferSchema.parse(offerPayload);
|
||||||
|
|
||||||
if (targetServerId && offer.serverId !== targetServerId) {
|
|
||||||
lastScannedRef.current = null;
|
|
||||||
Alert.alert(
|
|
||||||
"Wrong daemon",
|
|
||||||
`That QR code belongs to ${offer.serverId}, not ${targetServerId}.`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { client, hostname } = await connectToDaemon(
|
const { client, hostname } = await connectToDaemon(
|
||||||
{
|
{
|
||||||
id: "probe",
|
id: "probe",
|
||||||
@@ -242,7 +186,7 @@ export default function PairScanScreen() {
|
|||||||
|
|
||||||
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
|
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
|
||||||
|
|
||||||
returnToSource(profile.serverId);
|
navigateToPairedHost(profile.serverId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastScannedRef.current = null;
|
lastScannedRef.current = null;
|
||||||
const message = error instanceof Error ? error.message : "Unable to pair host";
|
const message = error instanceof Error ? error.message : "Unable to pair host";
|
||||||
@@ -251,18 +195,13 @@ export default function PairScanScreen() {
|
|||||||
setIsPairing(false);
|
setIsPairing(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
[isPairing, navigateToPairedHost, upsertDaemonFromOfferUrl],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
<BackHeader title="Scan QR" onBack={() => router.back()} />
|
||||||
<Text style={styles.headerTitle}>Scan QR</Text>
|
|
||||||
<Pressable onPress={() => router.back()}>
|
|
||||||
<Text style={styles.headerButtonText}>Close</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
|
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
|
||||||
<View style={styles.permissionCard}>
|
<View style={styles.permissionCard}>
|
||||||
<Text style={styles.permissionTitle}>Not available on web</Text>
|
<Text style={styles.permissionTitle}>Not available on web</Text>
|
||||||
@@ -282,12 +221,7 @@ export default function PairScanScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
<BackHeader title="Scan QR" onBack={closeToSource} />
|
||||||
<Text style={styles.headerTitle}>Scan QR</Text>
|
|
||||||
<Pressable onPress={closeToSource}>
|
|
||||||
<Text style={styles.headerButtonText}>Close</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
|
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
|
||||||
{!granted ? (
|
{!granted ? (
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
|
||||||
import { buildHostSettingsRoute } from "@/utils/host-routes";
|
|
||||||
|
|
||||||
export default function LegacySettingsRoute() {
|
|
||||||
const router = useRouter();
|
|
||||||
const daemons = useHosts();
|
|
||||||
|
|
||||||
const targetServerId = useMemo(() => {
|
|
||||||
return daemons[0]?.serverId ?? null;
|
|
||||||
}, [daemons]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!targetServerId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.replace(buildHostSettingsRoute(targetServerId));
|
|
||||||
}, [router, targetServerId]);
|
|
||||||
|
|
||||||
if (!targetServerId) {
|
|
||||||
return <DraftAgentScreen />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import SettingsScreen from "@/screens/settings-screen";
|
||||||
|
import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes";
|
||||||
|
|
||||||
|
export default function SettingsSectionRoute() {
|
||||||
|
const params = useLocalSearchParams<{ section?: string }>();
|
||||||
|
const rawSection = typeof params.section === "string" ? params.section : "";
|
||||||
|
const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general";
|
||||||
|
|
||||||
|
return <SettingsScreen view={{ kind: "section", section }} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
|
import SettingsScreen from "@/screens/settings-screen";
|
||||||
|
|
||||||
|
export default function SettingsHostRoute() {
|
||||||
|
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||||
|
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HostRouteBootstrapBoundary>
|
||||||
|
<SettingsScreen view={{ kind: "host", serverId }} />
|
||||||
|
</HostRouteBootstrapBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Redirect } from "expo-router";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
import SettingsScreen from "@/screens/settings-screen";
|
||||||
|
import { buildSettingsSectionRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
|
export default function SettingsIndexRoute() {
|
||||||
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
|
|
||||||
|
if (!isCompactLayout) {
|
||||||
|
return <Redirect href={buildSettingsSectionRoute("general")} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SettingsScreen view={{ kind: "root" }} />;
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { GitHubSearchItem } from "@server/shared/messages";
|
||||||
|
|
||||||
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
|
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
|
||||||
|
|
||||||
export interface AttachmentMetadata {
|
export interface AttachmentMetadata {
|
||||||
@@ -15,6 +17,11 @@ export interface AttachmentMetadata {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ComposerAttachment =
|
||||||
|
| { kind: "image"; metadata: AttachmentMetadata }
|
||||||
|
| { kind: "github_issue"; item: GitHubSearchItem }
|
||||||
|
| { kind: "github_pr"; item: GitHubSearchItem };
|
||||||
|
|
||||||
export type AttachmentDataSource =
|
export type AttachmentDataSource =
|
||||||
| { kind: "blob"; blob: Blob }
|
| { kind: "blob"; blob: Blob }
|
||||||
| { kind: "data_url"; dataUrl: string }
|
| { kind: "data_url"; dataUrl: string }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
import { forwardRef, useCallback, useEffect, useMemo } from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||||
@@ -7,13 +7,18 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
||||||
import {
|
import {
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
BottomSheetScrollView,
|
BottomSheetScrollView,
|
||||||
BottomSheetTextInput,
|
BottomSheetTextInput,
|
||||||
type BottomSheetBackgroundProps,
|
type BottomSheetBackgroundProps,
|
||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import { X } from "lucide-react-native";
|
import { X } from "lucide-react-native";
|
||||||
|
import { FileDropZone } from "@/components/file-drop-zone";
|
||||||
|
import type { ImageAttachment } from "@/components/message-input";
|
||||||
|
import {
|
||||||
|
IsolatedBottomSheetModal,
|
||||||
|
useIsolatedBottomSheetVisibility,
|
||||||
|
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
type EscHandler = () => void;
|
type EscHandler = () => void;
|
||||||
@@ -65,20 +70,24 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
borderRadius: theme.borderRadius.xl,
|
borderRadius: theme.borderRadius.xl,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: theme.colors.surface2,
|
borderColor: theme.colors.surface2,
|
||||||
overflow: "hidden",
|
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
paddingHorizontal: theme.spacing[6],
|
paddingHorizontal: theme.spacing[6],
|
||||||
paddingVertical: theme.spacing[4],
|
paddingVertical: theme.spacing[4],
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "flex-start",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: theme.colors.surface2,
|
borderBottomColor: theme.colors.surface2,
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
},
|
||||||
|
headerTitleGroup: {
|
||||||
|
flex: 1,
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.lg,
|
fontSize: theme.fontSize.lg,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.medium,
|
||||||
},
|
},
|
||||||
@@ -103,18 +112,16 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[4],
|
gap: theme.spacing[4],
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
},
|
},
|
||||||
bottomSheetHandle: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
bottomSheetHeader: {
|
bottomSheetHeader: {
|
||||||
paddingHorizontal: theme.spacing[6],
|
paddingHorizontal: theme.spacing[6],
|
||||||
paddingTop: theme.spacing[4],
|
paddingTop: theme.spacing[4],
|
||||||
paddingBottom: theme.spacing[3],
|
paddingBottom: theme.spacing[3],
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "flex-start",
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: theme.colors.surface2,
|
borderBottomColor: theme.colors.surface2,
|
||||||
|
gap: theme.spacing[3],
|
||||||
},
|
},
|
||||||
bottomSheetContent: {
|
bottomSheetContent: {
|
||||||
padding: theme.spacing[6],
|
padding: theme.spacing[6],
|
||||||
@@ -152,56 +159,43 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
|||||||
|
|
||||||
export interface AdaptiveModalSheetProps {
|
export interface AdaptiveModalSheetProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
/** Optional content rendered below the title in the header area. */
|
||||||
|
subtitle?: ReactNode;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
headerActions?: ReactNode;
|
headerActions?: ReactNode;
|
||||||
snapPoints?: string[];
|
snapPoints?: string[];
|
||||||
stackBehavior?: "push" | "switch" | "replace";
|
|
||||||
testID?: string;
|
testID?: string;
|
||||||
|
/** Override the max width of the desktop card. */
|
||||||
|
desktopMaxWidth?: number;
|
||||||
|
/** When provided, wraps the card content in a FileDropZone. */
|
||||||
|
onFilesDropped?: (files: ImageAttachment[]) => void;
|
||||||
scrollable?: boolean;
|
scrollable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdaptiveModalSheet({
|
export function AdaptiveModalSheet({
|
||||||
title,
|
title,
|
||||||
|
subtitle,
|
||||||
visible,
|
visible,
|
||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
headerActions,
|
headerActions,
|
||||||
snapPoints,
|
snapPoints,
|
||||||
stackBehavior,
|
|
||||||
testID,
|
testID,
|
||||||
|
desktopMaxWidth,
|
||||||
|
onFilesDropped,
|
||||||
scrollable = true,
|
scrollable = true,
|
||||||
}: AdaptiveModalSheetProps) {
|
}: AdaptiveModalSheetProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const sheetRef = useRef<BottomSheetModal>(null);
|
const titleColor = theme.colors.foreground;
|
||||||
const dismissingForVisibilityRef = useRef(false);
|
|
||||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||||
|
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
|
||||||
useEffect(() => {
|
visible,
|
||||||
if (!isMobile) return;
|
isEnabled: isMobile,
|
||||||
if (visible) {
|
onClose,
|
||||||
dismissingForVisibilityRef.current = false;
|
});
|
||||||
sheetRef.current?.present();
|
|
||||||
} else {
|
|
||||||
dismissingForVisibilityRef.current = true;
|
|
||||||
sheetRef.current?.dismiss();
|
|
||||||
}
|
|
||||||
}, [visible, isMobile]);
|
|
||||||
|
|
||||||
const handleSheetChange = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
if (index === -1) {
|
|
||||||
if (dismissingForVisibilityRef.current) {
|
|
||||||
dismissingForVisibilityRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||||
@@ -217,7 +211,7 @@ export function AdaptiveModalSheet({
|
|||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<BottomSheetModal
|
<IsolatedBottomSheetModal
|
||||||
ref={sheetRef}
|
ref={sheetRef}
|
||||||
snapPoints={resolvedSnapPoints}
|
snapPoints={resolvedSnapPoints}
|
||||||
index={0}
|
index={0}
|
||||||
@@ -225,16 +219,19 @@ export function AdaptiveModalSheet({
|
|||||||
onChange={handleSheetChange}
|
onChange={handleSheetChange}
|
||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
enablePanDownToClose
|
enablePanDownToClose
|
||||||
stackBehavior={stackBehavior}
|
|
||||||
backgroundComponent={SheetBackground}
|
backgroundComponent={SheetBackground}
|
||||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
handleIndicatorStyle={{ backgroundColor: theme.colors.surface2 }}
|
||||||
keyboardBehavior="extend"
|
keyboardBehavior="extend"
|
||||||
keyboardBlurBehavior="restore"
|
keyboardBlurBehavior="restore"
|
||||||
|
accessible={false}
|
||||||
>
|
>
|
||||||
<View style={styles.bottomSheetHeader}>
|
<View style={styles.bottomSheetHeader} testID={testID}>
|
||||||
<Text style={styles.title} numberOfLines={1}>
|
<View style={styles.headerTitleGroup}>
|
||||||
{title}
|
<Text key={titleColor} style={[styles.title, { color: titleColor }]} numberOfLines={1}>
|
||||||
</Text>
|
{title}
|
||||||
|
</Text>
|
||||||
|
{subtitle}
|
||||||
|
</View>
|
||||||
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
|
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
|
||||||
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
||||||
<X size={16} color={theme.colors.foregroundMuted} />
|
<X size={16} color={theme.colors.foregroundMuted} />
|
||||||
@@ -251,10 +248,39 @@ export function AdaptiveModalSheet({
|
|||||||
) : (
|
) : (
|
||||||
<View style={styles.bottomSheetStaticContent}>{children}</View>
|
<View style={styles.bottomSheetStaticContent}>{children}</View>
|
||||||
)}
|
)}
|
||||||
</BottomSheetModal>
|
</IsolatedBottomSheetModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cardInner = (
|
||||||
|
<>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<View style={styles.headerTitleGroup}>
|
||||||
|
<Text key={titleColor} style={[styles.title, { color: titleColor }]} numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{subtitle}
|
||||||
|
</View>
|
||||||
|
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
|
||||||
|
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
||||||
|
<X size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
{scrollable ? (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.desktopScroll}
|
||||||
|
contentContainerStyle={styles.desktopContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
<View style={styles.desktopStaticContent}>{children}</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
const desktopContent = (
|
const desktopContent = (
|
||||||
<View style={styles.desktopOverlay} testID={testID}>
|
<View style={styles.desktopOverlay} testID={testID}>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -262,27 +288,11 @@ export function AdaptiveModalSheet({
|
|||||||
style={{ ...StyleSheet.absoluteFillObject }}
|
style={{ ...StyleSheet.absoluteFillObject }}
|
||||||
onPress={onClose}
|
onPress={onClose}
|
||||||
/>
|
/>
|
||||||
<View style={styles.desktopCard}>
|
<View style={[styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }]}>
|
||||||
<View style={styles.header}>
|
{onFilesDropped ? (
|
||||||
<Text style={styles.title} numberOfLines={1}>
|
<FileDropZone onFilesDropped={onFilesDropped}>{cardInner}</FileDropZone>
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
|
|
||||||
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
|
||||||
<X size={16} color={theme.colors.foregroundMuted} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
{scrollable ? (
|
|
||||||
<ScrollView
|
|
||||||
style={styles.desktopScroll}
|
|
||||||
contentContainerStyle={styles.desktopContent}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ScrollView>
|
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.desktopStaticContent}>{children}</View>
|
cardInner
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ export function AddHostMethodModal({
|
|||||||
<Pressable
|
<Pressable
|
||||||
style={styles.option}
|
style={styles.option}
|
||||||
onPress={handleDirect}
|
onPress={handleDirect}
|
||||||
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Direct connection"
|
accessibilityLabel="Direct connection"
|
||||||
|
testID="add-host-method-direct"
|
||||||
>
|
>
|
||||||
<Link2 size={18} color={theme.colors.foreground} />
|
<Link2 size={18} color={theme.colors.foreground} />
|
||||||
<View style={styles.optionBody}>
|
<View style={styles.optionBody}>
|
||||||
@@ -80,7 +82,12 @@ export function AddHostMethodModal({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
{isNative ? (
|
{isNative ? (
|
||||||
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
|
<Pressable
|
||||||
|
style={styles.option}
|
||||||
|
onPress={handleScan}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Scan QR code"
|
||||||
|
>
|
||||||
<QrCode size={18} color={theme.colors.foreground} />
|
<QrCode size={18} color={theme.colors.foreground} />
|
||||||
<View style={styles.optionBody}>
|
<View style={styles.optionBody}>
|
||||||
<Text style={styles.optionText}>Scan QR code</Text>
|
<Text style={styles.optionText}>Scan QR code</Text>
|
||||||
@@ -92,7 +99,9 @@ export function AddHostMethodModal({
|
|||||||
<Pressable
|
<Pressable
|
||||||
style={styles.option}
|
style={styles.option}
|
||||||
onPress={handlePaste}
|
onPress={handlePaste}
|
||||||
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Paste pairing link"
|
accessibilityLabel="Paste pairing link"
|
||||||
|
testID="add-host-method-pair-link"
|
||||||
>
|
>
|
||||||
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
||||||
<View style={styles.optionBody}>
|
<View style={styles.optionBody}>
|
||||||
|
|||||||
@@ -132,7 +132,6 @@ function buildConnectionFailureCopy(
|
|||||||
export interface AddHostModalProps {
|
export interface AddHostModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
targetServerId?: string;
|
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
onSaved?: (result: {
|
onSaved?: (result: {
|
||||||
profile: HostProfile;
|
profile: HostProfile;
|
||||||
@@ -142,42 +141,41 @@ export interface AddHostModalProps {
|
|||||||
}) => void;
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddHostModal({
|
export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
onCancel,
|
|
||||||
onSaved,
|
|
||||||
targetServerId,
|
|
||||||
}: AddHostModalProps) {
|
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { upsertDirectConnection } = useHostMutations();
|
const { upsertDirectConnection } = useHostMutations();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
|
||||||
const hostInputRef = useRef<TextInput>(null);
|
const hostInputRef = useRef<TextInput>(null);
|
||||||
|
const endpointRawRef = useRef("");
|
||||||
|
|
||||||
const [endpointRaw, setEndpointRaw] = useState("");
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
const clearInput = useCallback(() => {
|
||||||
|
endpointRawRef.current = "";
|
||||||
|
hostInputRef.current?.clear();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
setEndpointRaw("");
|
clearInput();
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
onClose();
|
onClose();
|
||||||
}, [isSaving, onClose]);
|
}, [isSaving, clearInput, onClose]);
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
setEndpointRaw("");
|
clearInput();
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
(onCancel ?? onClose)();
|
(onCancel ?? onClose)();
|
||||||
}, [isSaving, onCancel, onClose]);
|
}, [isSaving, clearInput, onCancel, onClose]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
|
|
||||||
const raw = endpointRaw.trim();
|
const raw = endpointRawRef.current.trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
setErrorMessage("Host is required");
|
setErrorMessage("Host is required");
|
||||||
return;
|
return;
|
||||||
@@ -206,15 +204,6 @@ export function AddHostModal({
|
|||||||
endpoint,
|
endpoint,
|
||||||
});
|
});
|
||||||
await client.close().catch(() => undefined);
|
await client.close().catch(() => undefined);
|
||||||
if (targetServerId && serverId !== targetServerId) {
|
|
||||||
const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`;
|
|
||||||
setErrorMessage(message);
|
|
||||||
if (!isMobile) {
|
|
||||||
Alert.alert("Wrong daemon", message);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
|
||||||
const profile = await upsertDirectConnection({
|
const profile = await upsertDirectConnection({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -240,16 +229,7 @@ export function AddHostModal({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDirectConnection]);
|
||||||
daemons,
|
|
||||||
endpointRaw,
|
|
||||||
handleClose,
|
|
||||||
isMobile,
|
|
||||||
isSaving,
|
|
||||||
onSaved,
|
|
||||||
targetServerId,
|
|
||||||
upsertDirectConnection,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdaptiveModalSheet
|
<AdaptiveModalSheet
|
||||||
@@ -264,8 +244,12 @@ export function AddHostModal({
|
|||||||
<Text style={styles.label}>Host</Text>
|
<Text style={styles.label}>Host</Text>
|
||||||
<AdaptiveTextInput
|
<AdaptiveTextInput
|
||||||
ref={hostInputRef}
|
ref={hostInputRef}
|
||||||
value={endpointRaw}
|
testID="direct-host-input"
|
||||||
onChangeText={setEndpointRaw}
|
nativeID="direct-host-input"
|
||||||
|
accessibilityLabel="direct-host-input"
|
||||||
|
onChangeText={(next) => {
|
||||||
|
endpointRawRef.current = next;
|
||||||
|
}}
|
||||||
placeholder="hostname:port"
|
placeholder="hostname:port"
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
@@ -289,6 +273,7 @@ export function AddHostModal({
|
|||||||
onPress={() => void handleSave()}
|
onPress={() => void handleSave()}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
leftIcon={<Link2 size={16} color={theme.colors.palette.white} />}
|
leftIcon={<Link2 size={16} color={theme.colors.palette.white} />}
|
||||||
|
testID="direct-host-submit"
|
||||||
>
|
>
|
||||||
{isSaving ? "Connecting..." : "Connect"}
|
{isSaving ? "Connecting..." : "Connect"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-nativ
|
|||||||
import type { StyleProp, ViewStyle, TextProps } from "react-native";
|
import type { StyleProp, ViewStyle, TextProps } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetScrollView,
|
BottomSheetScrollView,
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
BottomSheetBackgroundProps,
|
BottomSheetBackgroundProps,
|
||||||
@@ -22,7 +21,6 @@ import {
|
|||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { theme as defaultTheme } from "@/styles/theme";
|
|
||||||
import type {
|
import type {
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
@@ -31,6 +29,10 @@ import type {
|
|||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
|
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
|
||||||
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
||||||
|
import {
|
||||||
|
IsolatedBottomSheetModal,
|
||||||
|
useIsolatedBottomSheetVisibility,
|
||||||
|
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||||
import { baseColors } from "@/styles/theme";
|
import { baseColors } from "@/styles/theme";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
@@ -86,6 +88,8 @@ export function DropdownField({
|
|||||||
renderTrigger,
|
renderTrigger,
|
||||||
testID,
|
testID,
|
||||||
}: DropdownFieldProps): ReactElement {
|
}: DropdownFieldProps): ReactElement {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
if (renderTrigger) {
|
if (renderTrigger) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -115,7 +119,7 @@ export function DropdownField({
|
|||||||
<Text style={value ? styles.dropdownValue : styles.dropdownPlaceholder} numberOfLines={1}>
|
<Text style={value ? styles.dropdownValue : styles.dropdownPlaceholder} numberOfLines={1}>
|
||||||
{value || placeholder}
|
{value || placeholder}
|
||||||
</Text>
|
</Text>
|
||||||
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||||
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
|
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
|
||||||
@@ -151,6 +155,8 @@ export function SelectField({
|
|||||||
valueEllipsizeMode,
|
valueEllipsizeMode,
|
||||||
testID,
|
testID,
|
||||||
}: SelectFieldProps): ReactElement {
|
}: SelectFieldProps): ReactElement {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
const getWebKey = useCallback((event: unknown): string | null => {
|
const getWebKey = useCallback((event: unknown): string | null => {
|
||||||
if (!event || typeof event !== "object") return null;
|
if (!event || typeof event !== "object") return null;
|
||||||
const eventWithNative = event as { nativeEvent?: unknown; key?: unknown };
|
const eventWithNative = event as { nativeEvent?: unknown; key?: unknown };
|
||||||
@@ -210,7 +216,7 @@ export function SelectField({
|
|||||||
{value || placeholder || "Select..."}
|
{value || placeholder || "Select..."}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<ChevronRight size={defaultTheme.iconSize.lg} color={defaultTheme.colors.foregroundMuted} />
|
<ChevronRight size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||||
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
|
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
|
||||||
@@ -229,7 +235,21 @@ interface DropdownSheetProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) {
|
function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||||
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />;
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[
|
||||||
|
style,
|
||||||
|
{
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||||
|
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DropdownSheet({
|
export function DropdownSheet({
|
||||||
@@ -238,31 +258,18 @@ export function DropdownSheet({
|
|||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
}: DropdownSheetProps): ReactElement {
|
}: DropdownSheetProps): ReactElement {
|
||||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
const { theme } = useUnistyles();
|
||||||
|
const titleColor = theme.colors.foreground;
|
||||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||||
|
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
bottomSheetRef.current?.dismiss();
|
|
||||||
onClose();
|
onClose();
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) {
|
|
||||||
bottomSheetRef.current?.present();
|
|
||||||
} else {
|
|
||||||
bottomSheetRef.current?.dismiss();
|
|
||||||
}
|
|
||||||
}, [visible]);
|
|
||||||
|
|
||||||
const handleSheetChange = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
if (index === -1) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
const renderBackdrop = useCallback(
|
||||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||||
@@ -271,8 +278,8 @@ export function DropdownSheet({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BottomSheetModal
|
<IsolatedBottomSheetModal
|
||||||
ref={bottomSheetRef}
|
ref={sheetRef}
|
||||||
snapPoints={snapPoints}
|
snapPoints={snapPoints}
|
||||||
index={0}
|
index={0}
|
||||||
enableDynamicSizing={false}
|
enableDynamicSizing={false}
|
||||||
@@ -280,12 +287,14 @@ export function DropdownSheet({
|
|||||||
backdropComponent={renderBackdrop}
|
backdropComponent={renderBackdrop}
|
||||||
enablePanDownToClose
|
enablePanDownToClose
|
||||||
backgroundComponent={DropdownSheetBackground}
|
backgroundComponent={DropdownSheetBackground}
|
||||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
|
||||||
keyboardBehavior="extend"
|
keyboardBehavior="extend"
|
||||||
keyboardBlurBehavior="restore"
|
keyboardBlurBehavior="restore"
|
||||||
>
|
>
|
||||||
<View style={styles.bottomSheetHeader}>
|
<View style={styles.bottomSheetHeader}>
|
||||||
<Text style={styles.dropdownSheetTitle}>{title}</Text>
|
<Text key={titleColor} style={[styles.dropdownSheetTitle, { color: titleColor }]}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Close sheet"
|
accessibilityLabel="Close sheet"
|
||||||
@@ -293,7 +302,7 @@ export function DropdownSheet({
|
|||||||
hitSlop={10}
|
hitSlop={10}
|
||||||
testID="dropdown-sheet-close"
|
testID="dropdown-sheet-close"
|
||||||
>
|
>
|
||||||
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
<X size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<BottomSheetScrollView
|
<BottomSheetScrollView
|
||||||
@@ -303,7 +312,7 @@ export function DropdownSheet({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</BottomSheetScrollView>
|
</BottomSheetScrollView>
|
||||||
</BottomSheetModal>
|
</IsolatedBottomSheetModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,6 +422,8 @@ export function FormSelectTrigger({
|
|||||||
valueEllipsizeMode,
|
valueEllipsizeMode,
|
||||||
testID,
|
testID,
|
||||||
}: CompactSelectFieldProps): ReactElement {
|
}: CompactSelectFieldProps): ReactElement {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
const getWebKey = useCallback((event: unknown): string | null => {
|
const getWebKey = useCallback((event: unknown): string | null => {
|
||||||
if (!event || typeof event !== "object") return null;
|
if (!event || typeof event !== "object") return null;
|
||||||
const eventWithNative = event as { nativeEvent?: unknown; key?: unknown };
|
const eventWithNative = event as { nativeEvent?: unknown; key?: unknown };
|
||||||
@@ -469,7 +480,7 @@ export function FormSelectTrigger({
|
|||||||
<View style={styles.compactSelectValueContainer}>
|
<View style={styles.compactSelectValueContainer}>
|
||||||
{showLabel ? <Text style={styles.compactSelectLabel}>{label}</Text> : null}
|
{showLabel ? <Text style={styles.compactSelectLabel}>{label}</Text> : null}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<ActivityIndicator size="small" color={defaultTheme.colors.foregroundMuted} />
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
) : (
|
) : (
|
||||||
<Text
|
<Text
|
||||||
style={hasConcreteValue ? styles.compactSelectValue : styles.compactSelectPlaceholder}
|
style={hasConcreteValue ? styles.compactSelectValue : styles.compactSelectPlaceholder}
|
||||||
@@ -480,7 +491,7 @@ export function FormSelectTrigger({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -518,6 +529,8 @@ export function AgentConfigRow({
|
|||||||
onSelectThinkingOption,
|
onSelectThinkingOption,
|
||||||
disabled,
|
disabled,
|
||||||
}: AgentConfigRowProps): ReactElement {
|
}: AgentConfigRowProps): ReactElement {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
const providerOptions: ComboSelectOption[] = useMemo(
|
const providerOptions: ComboSelectOption[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
providerDefinitions.map((def) => ({
|
providerDefinitions.map((def) => ({
|
||||||
@@ -576,7 +589,7 @@ export function AgentConfigRow({
|
|||||||
placeholder={providerOptions.length > 0 ? "Select..." : "No providers available"}
|
placeholder={providerOptions.length > 0 ? "Select..." : "No providers available"}
|
||||||
disabled={disabled || providerOptions.length === 0}
|
disabled={disabled || providerOptions.length === 0}
|
||||||
onSelect={onSelectProvider}
|
onSelect={onSelectProvider}
|
||||||
icon={<Bot size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
icon={<Bot size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
testID="draft-provider-select"
|
testID="draft-provider-select"
|
||||||
/>
|
/>
|
||||||
@@ -591,9 +604,7 @@ export function AgentConfigRow({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
isLoading={isModelLoading}
|
isLoading={isModelLoading}
|
||||||
onSelect={onSelectModel}
|
onSelect={onSelectModel}
|
||||||
icon={
|
icon={<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
|
||||||
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
|
||||||
}
|
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
testID="draft-model-select"
|
testID="draft-model-select"
|
||||||
/>
|
/>
|
||||||
@@ -607,7 +618,7 @@ export function AgentConfigRow({
|
|||||||
placeholder="Default"
|
placeholder="Default"
|
||||||
disabled={disabled || modeOptions.length === 0}
|
disabled={disabled || modeOptions.length === 0}
|
||||||
onSelect={onSelectMode}
|
onSelect={onSelectMode}
|
||||||
icon={<ModeIcon size={defaultTheme.iconSize.md} color={modeIconColor} />}
|
icon={<ModeIcon size={theme.iconSize.md} color={modeIconColor} />}
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
testID="draft-mode-select"
|
testID="draft-mode-select"
|
||||||
/>
|
/>
|
||||||
@@ -622,9 +633,7 @@ export function AgentConfigRow({
|
|||||||
placeholder="Select..."
|
placeholder="Select..."
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onSelect={onSelectThinkingOption}
|
onSelect={onSelectThinkingOption}
|
||||||
icon={
|
icon={<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
|
||||||
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
|
||||||
}
|
|
||||||
showLabel={false}
|
showLabel={false}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -969,6 +978,8 @@ export function GitOptionsSection({
|
|||||||
attachWorktreeError,
|
attachWorktreeError,
|
||||||
onSelectWorktreePath,
|
onSelectWorktreePath,
|
||||||
}: GitOptionsSectionProps): ReactElement {
|
}: GitOptionsSectionProps): ReactElement {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
const isLoading = status === "loading";
|
const isLoading = status === "loading";
|
||||||
const isCreateMode = worktreeMode === "create";
|
const isCreateMode = worktreeMode === "create";
|
||||||
const isAttachMode = worktreeMode === "attach";
|
const isAttachMode = worktreeMode === "attach";
|
||||||
@@ -1103,7 +1114,7 @@ export function GitOptionsSection({
|
|||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
placeholder="branch name"
|
placeholder="branch name"
|
||||||
placeholderTextColor={defaultTheme.colors.foregroundMuted}
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
onSubmitEditing={handleConfirmEdit}
|
onSubmitEditing={handleConfirmEdit}
|
||||||
/>
|
/>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -1111,19 +1122,16 @@ export function GitOptionsSection({
|
|||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={styles.baseBranchIconButton}
|
style={styles.baseBranchIconButton}
|
||||||
>
|
>
|
||||||
<Check
|
<Check size={theme.iconSize.md} color={theme.colors.palette.green[500]} />
|
||||||
size={defaultTheme.iconSize.md}
|
|
||||||
color={defaultTheme.colors.palette.green[500]}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
|
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
|
||||||
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
<X size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<Pressable onPress={handleStartEdit} style={styles.baseBranchValueRow}>
|
<Pressable onPress={handleStartEdit} style={styles.baseBranchValueRow}>
|
||||||
<Text style={styles.baseBranchValue}>{displayBranch}</Text>
|
<Text style={styles.baseBranchValue}>{displayBranch}</Text>
|
||||||
<Pencil size={defaultTheme.iconSize.sm} color={defaultTheme.colors.foregroundMuted} />
|
<Pencil size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -1171,14 +1179,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
},
|
},
|
||||||
bottomSheetBackground: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
|
||||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
|
||||||
},
|
|
||||||
bottomSheetHandle: {
|
|
||||||
backgroundColor: theme.colors.palette.zinc[600],
|
|
||||||
},
|
|
||||||
bottomSheetHeader: {
|
bottomSheetHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -1220,7 +1220,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
dropdownSheetTitle: {
|
dropdownSheetTitle: {
|
||||||
fontSize: theme.fontSize.lg,
|
fontSize: theme.fontSize.lg,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.semibold,
|
||||||
color: theme.colors.foreground,
|
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
},
|
},
|
||||||
dropdownSheetScrollContent: {
|
dropdownSheetScrollContent: {
|
||||||
|
|||||||
@@ -24,19 +24,20 @@ describe("submitAgentInput", () => {
|
|||||||
});
|
});
|
||||||
const clearDraft = vi.fn();
|
const clearDraft = vi.fn();
|
||||||
const setUserInput = vi.fn();
|
const setUserInput = vi.fn();
|
||||||
const setSelectedImages = vi.fn();
|
const setAttachments = vi.fn();
|
||||||
const setSendError = vi.fn();
|
const setSendError = vi.fn();
|
||||||
const setIsProcessing = vi.fn();
|
const setIsProcessing = vi.fn();
|
||||||
|
|
||||||
const submitPromise = submitAgentInput({
|
const submitPromise = submitAgentInput({
|
||||||
message: " hello world ",
|
message: " hello world ",
|
||||||
|
attachments: [],
|
||||||
isAgentRunning: false,
|
isAgentRunning: false,
|
||||||
canSubmit: true,
|
canSubmit: true,
|
||||||
queueMessage,
|
queueMessage,
|
||||||
submitMessage,
|
submitMessage,
|
||||||
clearDraft,
|
clearDraft,
|
||||||
setUserInput,
|
setUserInput,
|
||||||
setSelectedImages,
|
setAttachments,
|
||||||
setSendError,
|
setSendError,
|
||||||
setIsProcessing,
|
setIsProcessing,
|
||||||
});
|
});
|
||||||
@@ -44,10 +45,55 @@ describe("submitAgentInput", () => {
|
|||||||
expect(queueMessage).not.toHaveBeenCalled();
|
expect(queueMessage).not.toHaveBeenCalled();
|
||||||
expect(submitMessage).toHaveBeenCalledWith({
|
expect(submitMessage).toHaveBeenCalledWith({
|
||||||
message: "hello world",
|
message: "hello world",
|
||||||
imageAttachments: undefined,
|
attachments: [],
|
||||||
});
|
});
|
||||||
expect(setUserInput).toHaveBeenCalledWith("");
|
expect(setUserInput).toHaveBeenCalledWith("");
|
||||||
expect(setSelectedImages).toHaveBeenCalledWith([]);
|
expect(setAttachments).toHaveBeenCalledWith([]);
|
||||||
|
expect(setSendError).toHaveBeenCalledWith(null);
|
||||||
|
expect(setIsProcessing).toHaveBeenCalledWith(true);
|
||||||
|
expect(clearDraft).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
deferred.resolve();
|
||||||
|
|
||||||
|
await expect(submitPromise).resolves.toBe("submitted");
|
||||||
|
expect(clearDraft).toHaveBeenCalledWith("sent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the composer before an in-flight submit resolves when requested", async () => {
|
||||||
|
const deferred = createDeferredPromise<void>();
|
||||||
|
const attachments = [{ id: "img-1" }];
|
||||||
|
const queueMessage = vi.fn();
|
||||||
|
const submitMessage = vi.fn(async () => {
|
||||||
|
await deferred.promise;
|
||||||
|
});
|
||||||
|
const clearDraft = vi.fn();
|
||||||
|
const setUserInput = vi.fn();
|
||||||
|
const setAttachments = vi.fn();
|
||||||
|
const setSendError = vi.fn();
|
||||||
|
const setIsProcessing = vi.fn();
|
||||||
|
|
||||||
|
const submitPromise = submitAgentInput({
|
||||||
|
message: " keep me ",
|
||||||
|
attachments,
|
||||||
|
submitBehavior: "preserve-and-lock",
|
||||||
|
isAgentRunning: false,
|
||||||
|
canSubmit: true,
|
||||||
|
queueMessage,
|
||||||
|
submitMessage,
|
||||||
|
clearDraft,
|
||||||
|
setUserInput,
|
||||||
|
setAttachments,
|
||||||
|
setSendError,
|
||||||
|
setIsProcessing,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queueMessage).not.toHaveBeenCalled();
|
||||||
|
expect(submitMessage).toHaveBeenCalledWith({
|
||||||
|
message: "keep me",
|
||||||
|
attachments,
|
||||||
|
});
|
||||||
|
expect(setUserInput).not.toHaveBeenCalled();
|
||||||
|
expect(setAttachments).not.toHaveBeenCalled();
|
||||||
expect(setSendError).toHaveBeenCalledWith(null);
|
expect(setSendError).toHaveBeenCalledWith(null);
|
||||||
expect(setIsProcessing).toHaveBeenCalledWith(true);
|
expect(setIsProcessing).toHaveBeenCalledWith(true);
|
||||||
expect(clearDraft).not.toHaveBeenCalled();
|
expect(clearDraft).not.toHaveBeenCalled();
|
||||||
@@ -63,21 +109,21 @@ describe("submitAgentInput", () => {
|
|||||||
const submitMessage = vi.fn();
|
const submitMessage = vi.fn();
|
||||||
const clearDraft = vi.fn();
|
const clearDraft = vi.fn();
|
||||||
const setUserInput = vi.fn();
|
const setUserInput = vi.fn();
|
||||||
const setSelectedImages = vi.fn();
|
const setAttachments = vi.fn();
|
||||||
const setSendError = vi.fn();
|
const setSendError = vi.fn();
|
||||||
const setIsProcessing = vi.fn();
|
const setIsProcessing = vi.fn();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
submitAgentInput({
|
submitAgentInput({
|
||||||
message: " queued message ",
|
message: " queued message ",
|
||||||
imageAttachments: [{ id: "img-1" }],
|
attachments: [{ id: "img-1" }],
|
||||||
isAgentRunning: true,
|
isAgentRunning: true,
|
||||||
canSubmit: true,
|
canSubmit: true,
|
||||||
queueMessage,
|
queueMessage,
|
||||||
submitMessage,
|
submitMessage,
|
||||||
clearDraft,
|
clearDraft,
|
||||||
setUserInput,
|
setUserInput,
|
||||||
setSelectedImages,
|
setAttachments,
|
||||||
setSendError,
|
setSendError,
|
||||||
setIsProcessing,
|
setIsProcessing,
|
||||||
}),
|
}),
|
||||||
@@ -85,11 +131,11 @@ describe("submitAgentInput", () => {
|
|||||||
|
|
||||||
expect(queueMessage).toHaveBeenCalledWith({
|
expect(queueMessage).toHaveBeenCalledWith({
|
||||||
message: "queued message",
|
message: "queued message",
|
||||||
imageAttachments: [{ id: "img-1" }],
|
attachments: [{ id: "img-1" }],
|
||||||
});
|
});
|
||||||
expect(submitMessage).not.toHaveBeenCalled();
|
expect(submitMessage).not.toHaveBeenCalled();
|
||||||
expect(setUserInput).toHaveBeenCalledWith("");
|
expect(setUserInput).toHaveBeenCalledWith("");
|
||||||
expect(setSelectedImages).toHaveBeenCalledWith([]);
|
expect(setAttachments).toHaveBeenCalledWith([]);
|
||||||
expect(setSendError).not.toHaveBeenCalled();
|
expect(setSendError).not.toHaveBeenCalled();
|
||||||
expect(setIsProcessing).not.toHaveBeenCalled();
|
expect(setIsProcessing).not.toHaveBeenCalled();
|
||||||
expect(clearDraft).not.toHaveBeenCalled();
|
expect(clearDraft).not.toHaveBeenCalled();
|
||||||
@@ -103,23 +149,23 @@ describe("submitAgentInput", () => {
|
|||||||
});
|
});
|
||||||
const clearDraft = vi.fn();
|
const clearDraft = vi.fn();
|
||||||
const setUserInput = vi.fn();
|
const setUserInput = vi.fn();
|
||||||
const setSelectedImages = vi.fn();
|
const setAttachments = vi.fn();
|
||||||
const setSendError = vi.fn();
|
const setSendError = vi.fn();
|
||||||
const setIsProcessing = vi.fn();
|
const setIsProcessing = vi.fn();
|
||||||
const onSubmitError = vi.fn();
|
const onSubmitError = vi.fn();
|
||||||
const imageAttachments = [{ id: "img-1" }];
|
const attachments = [{ id: "img-1" }];
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
submitAgentInput({
|
submitAgentInput({
|
||||||
message: " hello world ",
|
message: " hello world ",
|
||||||
imageAttachments,
|
attachments,
|
||||||
isAgentRunning: false,
|
isAgentRunning: false,
|
||||||
canSubmit: true,
|
canSubmit: true,
|
||||||
queueMessage,
|
queueMessage,
|
||||||
submitMessage,
|
submitMessage,
|
||||||
clearDraft,
|
clearDraft,
|
||||||
setUserInput,
|
setUserInput,
|
||||||
setSelectedImages,
|
setAttachments,
|
||||||
setSendError,
|
setSendError,
|
||||||
setIsProcessing,
|
setIsProcessing,
|
||||||
onSubmitError,
|
onSubmitError,
|
||||||
@@ -129,12 +175,46 @@ describe("submitAgentInput", () => {
|
|||||||
expect(onSubmitError).toHaveBeenCalledWith(submitError);
|
expect(onSubmitError).toHaveBeenCalledWith(submitError);
|
||||||
expect(setUserInput).toHaveBeenNthCalledWith(1, "");
|
expect(setUserInput).toHaveBeenNthCalledWith(1, "");
|
||||||
expect(setUserInput).toHaveBeenNthCalledWith(2, "hello world");
|
expect(setUserInput).toHaveBeenNthCalledWith(2, "hello world");
|
||||||
expect(setSelectedImages).toHaveBeenNthCalledWith(1, []);
|
expect(setAttachments).toHaveBeenNthCalledWith(1, []);
|
||||||
expect(setSelectedImages).toHaveBeenNthCalledWith(2, imageAttachments);
|
expect(setAttachments).toHaveBeenNthCalledWith(2, attachments);
|
||||||
expect(setSendError).toHaveBeenNthCalledWith(1, null);
|
expect(setSendError).toHaveBeenNthCalledWith(1, null);
|
||||||
expect(setSendError).toHaveBeenNthCalledWith(2, "No host selected");
|
expect(setSendError).toHaveBeenNthCalledWith(2, "No host selected");
|
||||||
expect(setIsProcessing).toHaveBeenNthCalledWith(1, true);
|
expect(setIsProcessing).toHaveBeenNthCalledWith(1, true);
|
||||||
expect(setIsProcessing).toHaveBeenNthCalledWith(2, false);
|
expect(setIsProcessing).toHaveBeenNthCalledWith(2, false);
|
||||||
expect(clearDraft).not.toHaveBeenCalled();
|
expect(clearDraft).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("submits when empty submit is explicitly allowed", async () => {
|
||||||
|
const queueMessage = vi.fn();
|
||||||
|
const submitMessage = vi.fn(async () => {});
|
||||||
|
const clearDraft = vi.fn();
|
||||||
|
const setUserInput = vi.fn();
|
||||||
|
const setAttachments = vi.fn();
|
||||||
|
const setSendError = vi.fn();
|
||||||
|
const setIsProcessing = vi.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
submitAgentInput({
|
||||||
|
message: " ",
|
||||||
|
attachments: [],
|
||||||
|
allowEmptySubmit: true,
|
||||||
|
isAgentRunning: false,
|
||||||
|
canSubmit: true,
|
||||||
|
queueMessage,
|
||||||
|
submitMessage,
|
||||||
|
clearDraft,
|
||||||
|
setUserInput,
|
||||||
|
setAttachments,
|
||||||
|
setSendError,
|
||||||
|
setIsProcessing,
|
||||||
|
}),
|
||||||
|
).resolves.toBe("submitted");
|
||||||
|
|
||||||
|
expect(queueMessage).not.toHaveBeenCalled();
|
||||||
|
expect(submitMessage).toHaveBeenCalledWith({
|
||||||
|
message: "",
|
||||||
|
attachments: [],
|
||||||
|
});
|
||||||
|
expect(clearDraft).toHaveBeenCalledWith("sent");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,28 +1,37 @@
|
|||||||
export type AgentInputSubmitResult = "noop" | "queued" | "submitted" | "failed";
|
export type AgentInputSubmitResult = "noop" | "queued" | "submitted" | "failed";
|
||||||
|
|
||||||
export interface AgentInputSubmitActionInput<TImage> {
|
export interface AgentInputSubmitActionInput<TAttachment> {
|
||||||
message: string;
|
message: string;
|
||||||
imageAttachments?: TImage[];
|
attachments: TAttachment[];
|
||||||
|
hasExternalContent?: boolean;
|
||||||
|
allowEmptySubmit?: boolean;
|
||||||
|
submitBehavior?: "clear" | "preserve-and-lock";
|
||||||
forceSend?: boolean;
|
forceSend?: boolean;
|
||||||
isAgentRunning: boolean;
|
isAgentRunning: boolean;
|
||||||
canSubmit: boolean;
|
canSubmit: boolean;
|
||||||
queueMessage: (input: { message: string; imageAttachments?: TImage[] }) => void;
|
queueMessage: (input: { message: string; attachments: TAttachment[] }) => void;
|
||||||
submitMessage: (input: { message: string; imageAttachments?: TImage[] }) => Promise<void>;
|
submitMessage: (input: { message: string; attachments: TAttachment[] }) => Promise<void>;
|
||||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||||
setUserInput: (text: string) => void;
|
setUserInput: (text: string) => void;
|
||||||
setSelectedImages: (images: TImage[]) => void;
|
setAttachments: (attachments: TAttachment[]) => void;
|
||||||
setSendError: (message: string | null) => void;
|
setSendError: (message: string | null) => void;
|
||||||
setIsProcessing: (isProcessing: boolean) => void;
|
setIsProcessing: (isProcessing: boolean) => void;
|
||||||
onSubmitError?: (error: unknown) => void;
|
onSubmitError?: (error: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function submitAgentInput<TImage>(
|
export async function submitAgentInput<TAttachment>(
|
||||||
input: AgentInputSubmitActionInput<TImage>,
|
input: AgentInputSubmitActionInput<TAttachment>,
|
||||||
): Promise<AgentInputSubmitResult> {
|
): Promise<AgentInputSubmitResult> {
|
||||||
const trimmedMessage = input.message.trim();
|
const trimmedMessage = input.message.trim();
|
||||||
const imageAttachments = input.imageAttachments;
|
const attachments = input.attachments;
|
||||||
|
const shouldClearOnSubmit = input.submitBehavior !== "preserve-and-lock";
|
||||||
|
|
||||||
if (!trimmedMessage && !imageAttachments?.length) {
|
if (
|
||||||
|
!trimmedMessage &&
|
||||||
|
attachments.length === 0 &&
|
||||||
|
!input.hasExternalContent &&
|
||||||
|
!input.allowEmptySubmit
|
||||||
|
) {
|
||||||
return "noop";
|
return "noop";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,27 +40,33 @@ export async function submitAgentInput<TImage>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (input.isAgentRunning && !input.forceSend) {
|
if (input.isAgentRunning && !input.forceSend) {
|
||||||
input.queueMessage({ message: trimmedMessage, imageAttachments });
|
input.queueMessage({ message: trimmedMessage, attachments });
|
||||||
input.setUserInput("");
|
if (shouldClearOnSubmit) {
|
||||||
input.setSelectedImages([]);
|
input.setUserInput("");
|
||||||
|
input.setAttachments([]);
|
||||||
|
}
|
||||||
return "queued";
|
return "queued";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear immediately so optimistic stream updates and composer state stay in sync.
|
// Clear immediately so optimistic stream updates and composer state stay in sync.
|
||||||
input.setUserInput("");
|
if (shouldClearOnSubmit) {
|
||||||
input.setSelectedImages([]);
|
input.setUserInput("");
|
||||||
|
input.setAttachments([]);
|
||||||
|
}
|
||||||
input.setSendError(null);
|
input.setSendError(null);
|
||||||
input.setIsProcessing(true);
|
input.setIsProcessing(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await input.submitMessage({ message: trimmedMessage, imageAttachments });
|
await input.submitMessage({ message: trimmedMessage, attachments });
|
||||||
input.clearDraft("sent");
|
input.clearDraft("sent");
|
||||||
input.setIsProcessing(false);
|
input.setIsProcessing(false);
|
||||||
return "submitted";
|
return "submitted";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
input.onSubmitError?.(error);
|
input.onSubmitError?.(error);
|
||||||
input.setUserInput(trimmedMessage);
|
if (shouldClearOnSubmit) {
|
||||||
input.setSelectedImages(imageAttachments ?? []);
|
input.setUserInput(trimmedMessage);
|
||||||
|
input.setAttachments(attachments);
|
||||||
|
}
|
||||||
input.setSendError(error instanceof Error ? error.message : "Failed to send message");
|
input.setSendError(error instanceof Error ? error.message : "Failed to send message");
|
||||||
input.setIsProcessing(false);
|
input.setIsProcessing(false);
|
||||||
return "failed";
|
return "failed";
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import { shortenPath } from "@/utils/shorten-path";
|
|||||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { Archive } from "lucide-react-native";
|
import { Archive } from "lucide-react-native";
|
||||||
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
|
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||||
|
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
|
||||||
interface AgentListProps {
|
interface AgentListProps {
|
||||||
@@ -131,6 +134,7 @@ function SessionRow({
|
|||||||
const isSelected = selectedAgentId === agentKey;
|
const isSelected = selectedAgentId === agentKey;
|
||||||
const statusLabel = formatStatusLabel(agent.status);
|
const statusLabel = formatStatusLabel(agent.status);
|
||||||
const projectPath = shortenPath(agent.cwd);
|
const projectPath = shortenPath(agent.cwd);
|
||||||
|
const ProviderIcon = getProviderIcon(agent.provider);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -146,6 +150,9 @@ function SessionRow({
|
|||||||
>
|
>
|
||||||
<View style={styles.rowContent}>
|
<View style={styles.rowContent}>
|
||||||
<View style={styles.rowTitleRow}>
|
<View style={styles.rowTitleRow}>
|
||||||
|
<View style={styles.providerIconWrap}>
|
||||||
|
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
|
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
@@ -232,12 +239,21 @@ export function AgentList({
|
|||||||
|
|
||||||
const serverId = agent.serverId;
|
const serverId = agent.serverId;
|
||||||
const agentId = agent.id;
|
const agentId = agent.id;
|
||||||
|
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||||
|
workspaces: useSessionStore.getState().sessions[serverId]?.workspaces?.values(),
|
||||||
|
workspaceDirectory: agent.cwd,
|
||||||
|
});
|
||||||
|
|
||||||
onAgentSelect?.();
|
onAgentSelect?.();
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
router.navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const route = prepareWorkspaceTab({
|
const route = prepareWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId: agent.cwd,
|
workspaceId,
|
||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
pin: Boolean(agent.archivedAt),
|
pin: Boolean(agent.archivedAt),
|
||||||
});
|
});
|
||||||
@@ -247,7 +263,18 @@ export function AgentList({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
|
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
|
||||||
setActionAgent(agent);
|
const isRunning = agent.status === "running" || agent.status === "initializing";
|
||||||
|
if (isRunning) {
|
||||||
|
setActionAgent(agent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null;
|
||||||
|
if (!client) {
|
||||||
|
setActionAgent(agent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void client.archiveAgent(agent.id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCloseActionSheet = useCallback(() => {
|
const handleCloseActionSheet = useCallback(() => {
|
||||||
@@ -351,7 +378,9 @@ export function AgentList({
|
|||||||
>
|
>
|
||||||
<View style={styles.sheetHandle} />
|
<View style={styles.sheetHandle} />
|
||||||
<Text style={styles.sheetTitle}>
|
<Text style={styles.sheetTitle}>
|
||||||
{isActionDaemonUnavailable ? "Host offline" : "Archive this session?"}
|
{isActionDaemonUnavailable
|
||||||
|
? "Host offline"
|
||||||
|
: "This agent is still running. Archiving it will stop the agent."}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.sheetButtonRow}>
|
<View style={styles.sheetButtonRow}>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -435,6 +464,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
|
providerIconWrap: {
|
||||||
|
width: theme.iconSize.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
rowMetaRow: {
|
rowMetaRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ import {
|
|||||||
resolveAgentModelSelection,
|
resolveAgentModelSelection,
|
||||||
} from "@/components/agent-status-bar.utils";
|
} from "@/components/agent-status-bar.utils";
|
||||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||||
|
import { useToast } from "@/contexts/toast-context";
|
||||||
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
|
|
||||||
type StatusOption = {
|
type StatusOption = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -90,7 +92,7 @@ type ControlledAgentStatusBarProps = {
|
|||||||
|
|
||||||
export interface DraftAgentStatusBarProps {
|
export interface DraftAgentStatusBarProps {
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
selectedProvider: AgentProvider;
|
selectedProvider: AgentProvider | null;
|
||||||
onSelectProvider: (provider: AgentProvider) => void;
|
onSelectProvider: (provider: AgentProvider) => void;
|
||||||
modeOptions: AgentMode[];
|
modeOptions: AgentMode[];
|
||||||
selectedMode: string;
|
selectedMode: string;
|
||||||
@@ -253,7 +255,8 @@ function ControlledStatusBar({
|
|||||||
: undefined;
|
: undefined;
|
||||||
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
||||||
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
|
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
|
||||||
const ProviderIcon = getProviderIcon(provider);
|
const hasSelectedProvider = provider.trim().length > 0;
|
||||||
|
const ProviderIcon = hasSelectedProvider ? getProviderIcon(provider) : null;
|
||||||
|
|
||||||
const hasAnyControl =
|
const hasAnyControl =
|
||||||
Boolean(providerOptions?.length) ||
|
Boolean(providerOptions?.length) ||
|
||||||
@@ -424,12 +427,7 @@ function ControlledStatusBar({
|
|||||||
|
|
||||||
{thinkingOptions && thinkingOptions.length > 0 ? (
|
{thinkingOptions && thinkingOptions.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<Tooltip
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
key={`thinking-${openSelector === "thinking" ? "open" : "closed"}`}
|
|
||||||
delayDuration={0}
|
|
||||||
enabledOnDesktop
|
|
||||||
enabledOnMobile={false}
|
|
||||||
>
|
|
||||||
<TooltipTrigger asChild triggerRefProp="ref">
|
<TooltipTrigger asChild triggerRefProp="ref">
|
||||||
<Pressable
|
<Pressable
|
||||||
ref={thinkingAnchorRef}
|
ref={thinkingAnchorRef}
|
||||||
@@ -470,12 +468,7 @@ function ControlledStatusBar({
|
|||||||
|
|
||||||
{modeOptions && modeOptions.length > 0 ? (
|
{modeOptions && modeOptions.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<Tooltip
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
key={`mode-${openSelector === "mode" ? "open" : "closed"}`}
|
|
||||||
delayDuration={0}
|
|
||||||
enabledOnDesktop
|
|
||||||
enabledOnMobile={false}
|
|
||||||
>
|
|
||||||
<TooltipTrigger asChild triggerRefProp="ref">
|
<TooltipTrigger asChild triggerRefProp="ref">
|
||||||
<Pressable
|
<Pressable
|
||||||
ref={modeAnchorRef}
|
ref={modeAnchorRef}
|
||||||
@@ -628,7 +621,9 @@ function ControlledStatusBar({
|
|||||||
accessibilityLabel="Agent preferences"
|
accessibilityLabel="Agent preferences"
|
||||||
testID="agent-preferences-button"
|
testID="agent-preferences-button"
|
||||||
>
|
>
|
||||||
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
{ProviderIcon ? (
|
||||||
|
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||||
|
) : null}
|
||||||
<Text style={styles.prefsButtonText} numberOfLines={1}>
|
<Text style={styles.prefsButtonText} numberOfLines={1}>
|
||||||
{displayModel}
|
{displayModel}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -638,7 +633,6 @@ function ControlledStatusBar({
|
|||||||
title="Preferences"
|
title="Preferences"
|
||||||
visible={prefsOpen}
|
visible={prefsOpen}
|
||||||
onClose={() => setPrefsOpen(false)}
|
onClose={() => setPrefsOpen(false)}
|
||||||
stackBehavior="replace"
|
|
||||||
testID="agent-preferences-sheet"
|
testID="agent-preferences-sheet"
|
||||||
>
|
>
|
||||||
{canSelectModel ? (
|
{canSelectModel ? (
|
||||||
@@ -671,7 +665,12 @@ function ControlledStatusBar({
|
|||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
testID="agent-preferences-model"
|
testID="agent-preferences-model"
|
||||||
>
|
>
|
||||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
{ProviderIcon ? (
|
||||||
|
<ProviderIcon
|
||||||
|
size={theme.iconSize.md}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
||||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
</View>
|
||||||
@@ -873,23 +872,23 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
||||||
);
|
);
|
||||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
entries: snapshotEntries,
|
entries: snapshotEntries,
|
||||||
isLoading: snapshotIsLoading,
|
isLoading: snapshotIsLoading,
|
||||||
isFetching: snapshotIsFetching,
|
refetchIfStale: refetchSnapshotIfStale,
|
||||||
invalidate: invalidateSnapshot,
|
} = useProvidersSnapshot(serverId, agent?.cwd);
|
||||||
} = useProvidersSnapshot(serverId);
|
|
||||||
|
|
||||||
const snapshotModels = useMemo(() => {
|
const snapshotSelectedEntry = useMemo(() => {
|
||||||
if (!snapshotEntries || !agent?.provider) {
|
if (!snapshotEntries || !agent?.provider) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const entry = snapshotEntries.find((e) => e.provider === agent.provider);
|
return snapshotEntries.find((e) => e.provider === agent.provider) ?? null;
|
||||||
return entry?.models ?? null;
|
|
||||||
}, [snapshotEntries, agent?.provider]);
|
}, [snapshotEntries, agent?.provider]);
|
||||||
|
|
||||||
const models = snapshotModels;
|
const models = snapshotSelectedEntry?.models ?? null;
|
||||||
|
const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading";
|
||||||
|
|
||||||
const agentProviderDefinitions = useMemo(() => {
|
const agentProviderDefinitions = useMemo(() => {
|
||||||
const definition = agent?.provider
|
const definition = agent?.provider
|
||||||
@@ -900,11 +899,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
|
|
||||||
const agentProviderModels = useMemo(() => {
|
const agentProviderModels = useMemo(() => {
|
||||||
const map = new Map<string, AgentModelDefinition[]>();
|
const map = new Map<string, AgentModelDefinition[]>();
|
||||||
if (agent?.provider && snapshotModels) {
|
if (agent?.provider && models) {
|
||||||
map.set(agent.provider, snapshotModels);
|
map.set(agent.provider, models);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [agent?.provider, snapshotModels]);
|
}, [agent?.provider, models]);
|
||||||
|
|
||||||
const displayMode =
|
const displayMode =
|
||||||
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
||||||
@@ -964,6 +963,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
}
|
}
|
||||||
void client.setAgentMode(agentId, modeId).catch((error) => {
|
void client.setAgentMode(agentId, modeId).catch((error) => {
|
||||||
console.warn("[AgentStatusBar] setAgentMode failed", error);
|
console.warn("[AgentStatusBar] setAgentMode failed", error);
|
||||||
|
toast.error(toErrorMessage(error));
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
modelOptions={modelOptions}
|
modelOptions={modelOptions}
|
||||||
@@ -985,6 +985,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
});
|
});
|
||||||
void client.setAgentModel(agentId, modelId).catch((error) => {
|
void client.setAgentModel(agentId, modelId).catch((error) => {
|
||||||
console.warn("[AgentStatusBar] setAgentModel failed", error);
|
console.warn("[AgentStatusBar] setAgentModel failed", error);
|
||||||
|
toast.error(toErrorMessage(error));
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
@@ -1020,6 +1021,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
}
|
}
|
||||||
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
|
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
|
||||||
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
|
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
|
||||||
|
toast.error(toErrorMessage(error));
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
features={agent.features}
|
features={agent.features}
|
||||||
@@ -1042,10 +1044,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
});
|
});
|
||||||
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
|
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
|
||||||
console.warn("[AgentStatusBar] setAgentFeature failed", error);
|
console.warn("[AgentStatusBar] setAgentFeature failed", error);
|
||||||
|
toast.error(toErrorMessage(error));
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
isModelLoading={snapshotIsLoading || snapshotIsFetching}
|
isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
|
||||||
onModelSelectorOpen={invalidateSnapshot}
|
onModelSelectorOpen={() => refetchSnapshotIfStale(agent?.provider)}
|
||||||
onDropdownClose={onDropdownClose}
|
onDropdownClose={onDropdownClose}
|
||||||
disabled={!client}
|
disabled={!client}
|
||||||
/>
|
/>
|
||||||
@@ -1101,6 +1104,7 @@ export function DraftAgentStatusBar({
|
|||||||
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
|
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
|
||||||
const effectiveSelectedThinkingOption =
|
const effectiveSelectedThinkingOption =
|
||||||
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
|
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
|
||||||
|
const hasSelectedProvider = selectedProvider !== null;
|
||||||
|
|
||||||
if (platformIsWeb) {
|
if (platformIsWeb) {
|
||||||
return (
|
return (
|
||||||
@@ -1108,7 +1112,7 @@ export function DraftAgentStatusBar({
|
|||||||
<CombinedModelSelector
|
<CombinedModelSelector
|
||||||
providerDefinitions={providerDefinitions}
|
providerDefinitions={providerDefinitions}
|
||||||
allProviderModels={allProviderModels}
|
allProviderModels={allProviderModels}
|
||||||
selectedProvider={selectedProvider}
|
selectedProvider={selectedProvider ?? ""}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
onSelect={onSelectProviderAndModel}
|
onSelect={onSelectProviderAndModel}
|
||||||
favoriteKeys={favoriteKeys}
|
favoriteKeys={favoriteKeys}
|
||||||
@@ -1124,20 +1128,22 @@ export function DraftAgentStatusBar({
|
|||||||
onOpen={onModelSelectorOpen}
|
onOpen={onModelSelectorOpen}
|
||||||
onClose={onDropdownClose}
|
onClose={onDropdownClose}
|
||||||
/>
|
/>
|
||||||
<ControlledStatusBar
|
{selectedProvider ? (
|
||||||
provider={selectedProvider}
|
<ControlledStatusBar
|
||||||
providerDefinitions={providerDefinitions}
|
provider={selectedProvider}
|
||||||
modeOptions={mappedModeOptions}
|
providerDefinitions={providerDefinitions}
|
||||||
selectedModeId={effectiveSelectedMode}
|
modeOptions={mappedModeOptions}
|
||||||
onSelectMode={onSelectMode}
|
selectedModeId={effectiveSelectedMode}
|
||||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
onSelectMode={onSelectMode}
|
||||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||||
onSelectThinkingOption={onSelectThinkingOption}
|
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||||
features={features}
|
onSelectThinkingOption={onSelectThinkingOption}
|
||||||
onSetFeature={onSetFeature}
|
features={features}
|
||||||
onDropdownClose={onDropdownClose}
|
onSetFeature={onSetFeature}
|
||||||
disabled={disabled}
|
onDropdownClose={onDropdownClose}
|
||||||
/>
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1150,10 +1156,10 @@ export function DraftAgentStatusBar({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ControlledStatusBar
|
<ControlledStatusBar
|
||||||
provider={selectedProvider}
|
provider={selectedProvider ?? ""}
|
||||||
providerDefinitions={providerDefinitions}
|
providerDefinitions={providerDefinitions}
|
||||||
allProviderModels={allProviderModels}
|
allProviderModels={allProviderModels}
|
||||||
modeOptions={mappedModeOptions}
|
modeOptions={hasSelectedProvider ? mappedModeOptions : undefined}
|
||||||
selectedModeId={effectiveSelectedMode}
|
selectedModeId={effectiveSelectedMode}
|
||||||
onSelectMode={onSelectMode}
|
onSelectMode={onSelectMode}
|
||||||
modelOptions={modelOptions}
|
modelOptions={modelOptions}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
orderHeadForStreamRenderStrategy,
|
orderHeadForStreamRenderStrategy,
|
||||||
orderTailForStreamRenderStrategy,
|
orderTailForStreamRenderStrategy,
|
||||||
resolveStreamRenderStrategy,
|
|
||||||
} from "./stream-strategy";
|
} from "./stream-strategy";
|
||||||
|
import { resolveStreamRenderStrategy } from "./stream-strategy-resolver";
|
||||||
|
|
||||||
export type StreamRenderSegments = {
|
export type StreamRenderSegments = {
|
||||||
historyVirtualized: StreamItem[];
|
historyVirtualized: StreamItem[];
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from "./stream-strategy";
|
export * from "./stream-strategy";
|
||||||
|
export * from "./stream-strategy-resolver";
|
||||||
export * from "./agent-stream-render-model";
|
export * from "./agent-stream-render-model";
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import {
|
|||||||
} from "./use-bottom-anchor-controller";
|
} from "./use-bottom-anchor-controller";
|
||||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
||||||
|
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import {
|
import {
|
||||||
@@ -124,7 +125,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
|
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
|
||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||||
|
|
||||||
// Get serverId (fallback to agent's serverId if not provided)
|
// Get serverId (fallback to agent's serverId if not provided)
|
||||||
@@ -136,10 +137,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
);
|
);
|
||||||
|
|
||||||
const workspaceRoot = agent.cwd?.trim() || "";
|
const workspaceRoot = agent.cwd?.trim() || "";
|
||||||
const workspaceId = agent.projectPlacement?.checkout?.cwd?.trim() || workspaceRoot;
|
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||||
|
workspaces: useSessionStore.getState().sessions[resolvedServerId]?.workspaces?.values(),
|
||||||
|
workspaceDirectory: workspaceRoot,
|
||||||
|
});
|
||||||
const { requestDirectoryListing } = useFileExplorerActions({
|
const { requestDirectoryListing } = useFileExplorerActions({
|
||||||
serverId: resolvedServerId,
|
serverId: resolvedServerId,
|
||||||
workspaceId,
|
workspaceId: workspaceId ?? undefined,
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
});
|
});
|
||||||
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
|
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
|
||||||
@@ -179,12 +183,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = prepareWorkspaceTab({
|
if (workspaceId) {
|
||||||
serverId: resolvedServerId,
|
const route = prepareWorkspaceTab({
|
||||||
workspaceId,
|
serverId: resolvedServerId,
|
||||||
target: { kind: "file", path: normalized.file },
|
workspaceId,
|
||||||
});
|
target: { kind: "file", path: normalized.file },
|
||||||
router.navigate(route);
|
});
|
||||||
|
router.navigate(route);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,17 +199,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
setCurrentPath: false,
|
setCurrentPath: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
setExplorerTabForCheckout({
|
const checkout = {
|
||||||
serverId: resolvedServerId,
|
serverId: resolvedServerId,
|
||||||
cwd: agent.cwd,
|
cwd: agent.cwd,
|
||||||
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
|
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
|
||||||
tab: "files",
|
};
|
||||||
|
setExplorerTabForCheckout({ ...checkout, tab: "files" });
|
||||||
|
openFileExplorerForCheckout({
|
||||||
|
isCompact: isMobile,
|
||||||
|
checkout,
|
||||||
});
|
});
|
||||||
openFileExplorer();
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
agent.cwd,
|
agent.cwd,
|
||||||
openFileExplorer,
|
isMobile,
|
||||||
|
openFileExplorerForCheckout,
|
||||||
requestDirectoryListing,
|
requestDirectoryListing,
|
||||||
resolvedServerId,
|
resolvedServerId,
|
||||||
router,
|
router,
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
|
import { AttachmentLightbox } from "./attachment-lightbox";
|
||||||
|
|
||||||
|
const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
|
||||||
|
const theme = {
|
||||||
|
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
|
||||||
|
iconSize: { sm: 14, md: 18, lg: 22 },
|
||||||
|
borderWidth: { 1: 1 },
|
||||||
|
borderRadius: { full: 999, md: 6, lg: 8 },
|
||||||
|
fontSize: { xs: 11, sm: 13, base: 15 },
|
||||||
|
fontWeight: { normal: "400" },
|
||||||
|
colors: {
|
||||||
|
surface1: "#111",
|
||||||
|
surface2: "#222",
|
||||||
|
foreground: "#fff",
|
||||||
|
foregroundMuted: "#aaa",
|
||||||
|
border: "#555",
|
||||||
|
borderAccent: "#444",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageMetadata: AttachmentMetadata = {
|
||||||
|
id: "img-1",
|
||||||
|
mimeType: "image/png",
|
||||||
|
storageType: "web-indexeddb",
|
||||||
|
storageKey: "img-1",
|
||||||
|
fileName: "img-1.png",
|
||||||
|
byteSize: 42,
|
||||||
|
createdAt: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme,
|
||||||
|
imageMetadata,
|
||||||
|
useAttachmentPreviewUrlMock: vi.fn<(metadata: AttachmentMetadata | null) => string | null>(
|
||||||
|
() => "blob:preview",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-native-unistyles", () => ({
|
||||||
|
StyleSheet: {
|
||||||
|
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||||
|
},
|
||||||
|
useUnistyles: () => ({ theme }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/platform", () => ({
|
||||||
|
isWeb: true,
|
||||||
|
isNative: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-safe-area-context", () => ({
|
||||||
|
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("lucide-react-native", () => {
|
||||||
|
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||||
|
React.createElement("span", { ...props, "data-icon": name });
|
||||||
|
return {
|
||||||
|
X: createIcon("X"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("expo-image", () => ({
|
||||||
|
Image: (props: Record<string, unknown>) => {
|
||||||
|
const source = props.source as { uri?: string } | string | undefined;
|
||||||
|
const uri = typeof source === "string" ? source : source?.uri;
|
||||||
|
return React.createElement("div", {
|
||||||
|
"data-testid": props.testID,
|
||||||
|
"data-source": uri,
|
||||||
|
"data-style": JSON.stringify(props.style ?? null),
|
||||||
|
role: "img",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native", async () => {
|
||||||
|
const actual = await vi.importActual<Record<string, unknown>>("react-native");
|
||||||
|
const Modal = ({
|
||||||
|
visible = true,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
visible?: boolean;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) =>
|
||||||
|
visible ? React.createElement("div", { "data-testid": "lightbox-modal" }, children) : null;
|
||||||
|
return { ...actual, Modal };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/attachments/use-attachment-preview-url", () => ({
|
||||||
|
useAttachmentPreviewUrl: (metadata: AttachmentMetadata | null) =>
|
||||||
|
useAttachmentPreviewUrlMock(metadata),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLElement | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||||
|
vi.stubGlobal("React", React);
|
||||||
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||||
|
vi.stubGlobal("window", dom.window);
|
||||||
|
vi.stubGlobal("document", dom.window.document);
|
||||||
|
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
|
||||||
|
vi.stubGlobal("Node", dom.window.Node);
|
||||||
|
vi.stubGlobal("navigator", dom.window.navigator);
|
||||||
|
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
useAttachmentPreviewUrlMock.mockReturnValue("blob:preview");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root = null;
|
||||||
|
container = null;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function render(element: React.ReactElement) {
|
||||||
|
act(() => {
|
||||||
|
root?.render(element);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(element: Element) {
|
||||||
|
act(() => {
|
||||||
|
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryByTestId(testID: string): HTMLElement | null {
|
||||||
|
return document.querySelector(`[data-testid="${testID}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AttachmentLightbox", () => {
|
||||||
|
it("renders nothing when metadata is null", () => {
|
||||||
|
render(<AttachmentLightbox metadata={null} onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(queryByTestId("attachment-lightbox-backdrop")).toBeNull();
|
||||||
|
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the image when metadata is provided", () => {
|
||||||
|
render(<AttachmentLightbox metadata={imageMetadata} onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
const image = queryByTestId("attachment-lightbox-image");
|
||||||
|
expect(image).not.toBeNull();
|
||||||
|
expect(image?.getAttribute("data-source")).toBe("blob:preview");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fills its parent via absolute positioning so expo-image does not collapse to 0px", () => {
|
||||||
|
render(<AttachmentLightbox metadata={imageMetadata} onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
const image = queryByTestId("attachment-lightbox-image");
|
||||||
|
const style = JSON.parse(image?.getAttribute("data-style") ?? "null") as {
|
||||||
|
position?: string;
|
||||||
|
top?: number;
|
||||||
|
left?: number;
|
||||||
|
right?: number;
|
||||||
|
bottom?: number;
|
||||||
|
} | null;
|
||||||
|
expect(style?.position).toBe("absolute");
|
||||||
|
expect(style?.top).toBe(0);
|
||||||
|
expect(style?.left).toBe(0);
|
||||||
|
expect(style?.right).toBe(0);
|
||||||
|
expect(style?.bottom).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when the backdrop is pressed", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<AttachmentLightbox metadata={imageMetadata} onClose={onClose} />);
|
||||||
|
|
||||||
|
const backdrop = queryByTestId("attachment-lightbox-backdrop");
|
||||||
|
expect(backdrop).not.toBeNull();
|
||||||
|
click(backdrop!);
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when the close button is pressed", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<AttachmentLightbox metadata={imageMetadata} onClose={onClose} />);
|
||||||
|
|
||||||
|
const closeButton = document.querySelector(
|
||||||
|
'[aria-label="Close image"][data-testid="attachment-lightbox-close"]',
|
||||||
|
);
|
||||||
|
expect(closeButton).not.toBeNull();
|
||||||
|
click(closeButton!);
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error text when the preview URL resolves to null", () => {
|
||||||
|
useAttachmentPreviewUrlMock.mockReturnValue(null);
|
||||||
|
render(<AttachmentLightbox metadata={imageMetadata} onClose={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
|
||||||
|
expect(document.body.textContent ?? "").toContain("Couldn't load image");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes on Escape key on web", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<AttachmentLightbox metadata={imageMetadata} onClose={onClose} />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Modal, Pressable, Text, View } from "react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Image as ExpoImage } from "expo-image";
|
||||||
|
import { X } from "lucide-react-native";
|
||||||
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
|
interface AttachmentLightboxProps {
|
||||||
|
metadata: AttachmentMetadata | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const url = useAttachmentPreviewUrl(metadata);
|
||||||
|
const [errored, setErrored] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setErrored(false);
|
||||||
|
}, [metadata?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWeb || !metadata) return;
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeydown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeydown);
|
||||||
|
};
|
||||||
|
}, [metadata, onClose]);
|
||||||
|
|
||||||
|
if (!metadata) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasError = errored || !url;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
||||||
|
<View style={styles.root}>
|
||||||
|
<Pressable
|
||||||
|
testID="attachment-lightbox-backdrop"
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Dismiss image"
|
||||||
|
onPress={onClose}
|
||||||
|
style={styles.backdrop}
|
||||||
|
/>
|
||||||
|
<View style={styles.contentLayer}>
|
||||||
|
<View style={styles.imageArea}>
|
||||||
|
{hasError ? (
|
||||||
|
<Text style={styles.errorText}>Couldn't load image</Text>
|
||||||
|
) : (
|
||||||
|
<Pressable onPress={() => {}} style={styles.imagePressable}>
|
||||||
|
<ExpoImage
|
||||||
|
testID="attachment-lightbox-image"
|
||||||
|
source={{ uri: url }}
|
||||||
|
contentFit="contain"
|
||||||
|
onError={() => setErrored(true)}
|
||||||
|
style={imageFillStyle}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Pressable
|
||||||
|
testID="attachment-lightbox-close"
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Close image"
|
||||||
|
hitSlop={8}
|
||||||
|
onPress={onClose}
|
||||||
|
style={[
|
||||||
|
styles.closeButton,
|
||||||
|
{
|
||||||
|
top: insets.top + theme.spacing[3],
|
||||||
|
right: insets.right + theme.spacing[3],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<X size={16} color={theme.colors.foregroundMuted} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageFillStyle = {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
root: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
backdrop: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.9)",
|
||||||
|
},
|
||||||
|
contentLayer: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
pointerEvents: "box-none",
|
||||||
|
},
|
||||||
|
imageArea: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
pointerEvents: "box-none",
|
||||||
|
},
|
||||||
|
imagePressable: {
|
||||||
|
flex: 1,
|
||||||
|
width: "100%",
|
||||||
|
alignSelf: "center",
|
||||||
|
maxWidth: 960,
|
||||||
|
maxHeight: 640,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
position: "absolute",
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { type ReactNode, useState } from "react";
|
||||||
|
import { Pressable, View } from "react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { X } from "lucide-react-native";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
|
||||||
|
interface AttachmentPillProps {
|
||||||
|
onOpen: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
openAccessibilityLabel: string;
|
||||||
|
removeAccessibilityLabel: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
testID?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentPill({
|
||||||
|
onOpen,
|
||||||
|
onRemove,
|
||||||
|
openAccessibilityLabel,
|
||||||
|
removeAccessibilityLabel,
|
||||||
|
disabled = false,
|
||||||
|
testID,
|
||||||
|
children,
|
||||||
|
}: AttachmentPillProps) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const isCompact = useIsCompactFormFactor();
|
||||||
|
const [isBodyHovered, setIsBodyHovered] = useState(false);
|
||||||
|
const [isCloseHovered, setIsCloseHovered] = useState(false);
|
||||||
|
const alwaysShow = isNative || isCompact;
|
||||||
|
const showRemove = alwaysShow || isBodyHovered || isCloseHovered;
|
||||||
|
return (
|
||||||
|
<View style={styles.wrapper}>
|
||||||
|
<Pressable
|
||||||
|
testID={testID}
|
||||||
|
onPress={onOpen}
|
||||||
|
disabled={disabled}
|
||||||
|
onHoverIn={() => setIsBodyHovered(true)}
|
||||||
|
onHoverOut={() => setIsBodyHovered(false)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={openAccessibilityLabel}
|
||||||
|
style={styles.body}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={onRemove}
|
||||||
|
disabled={disabled}
|
||||||
|
onHoverIn={() => setIsCloseHovered(true)}
|
||||||
|
onHoverOut={() => setIsCloseHovered(false)}
|
||||||
|
hitSlop={8}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={removeAccessibilityLabel}
|
||||||
|
style={[styles.closeButton, !showRemove && styles.closeButtonHidden]}
|
||||||
|
>
|
||||||
|
<X size={12} color={theme.colors.foregroundMuted} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
wrapper: {
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.borderAccent,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
position: "absolute",
|
||||||
|
top: -8,
|
||||||
|
left: -8,
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
closeButtonHidden: {
|
||||||
|
opacity: 0,
|
||||||
|
pointerEvents: "none",
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, View } from "react-native";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { ChevronDown, GitBranch } from "lucide-react-native";
|
import { ChevronDown, GitBranch } from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -8,6 +8,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
|||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
|
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
|
||||||
|
import { ScreenTitle } from "@/components/headers/screen-title";
|
||||||
|
|
||||||
interface BranchSwitcherProps {
|
interface BranchSwitcherProps {
|
||||||
currentBranchName: string | null;
|
currentBranchName: string | null;
|
||||||
@@ -44,12 +45,10 @@ export function BranchSwitcher({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const titleContent = (
|
const titleContent = (
|
||||||
<>
|
<View style={styles.titleRow}>
|
||||||
{isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null}
|
{isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null}
|
||||||
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
|
<ScreenTitle testID="workspace-header-title">{title}</ScreenTitle>
|
||||||
{title}
|
</View>
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!currentBranchName) {
|
if (!currentBranchName) {
|
||||||
@@ -102,19 +101,11 @@ export function BranchSwitcher({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
headerTitle: {
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
fontWeight: {
|
|
||||||
xs: "400",
|
|
||||||
md: "300",
|
|
||||||
},
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
flexShrink: 1,
|
|
||||||
},
|
|
||||||
branchSwitcherTrigger: {
|
branchSwitcherTrigger: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
|
minWidth: 0,
|
||||||
marginLeft: {
|
marginLeft: {
|
||||||
xs: -theme.spacing[2],
|
xs: -theme.spacing[2],
|
||||||
md: 0,
|
md: 0,
|
||||||
@@ -126,9 +117,15 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
minWidth: 0,
|
|
||||||
},
|
},
|
||||||
branchSwitcherTriggerHovered: {
|
branchSwitcherTriggerHovered: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
},
|
},
|
||||||
|
titleRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ describe("combined model selector helpers", () => {
|
|||||||
expect(matchesSearch(rows[1]!, "gpt-5.4")).toBe(true);
|
expect(matchesSearch(rows[1]!, "gpt-5.4")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds an explicit trigger label for the selected provider and model", () => {
|
it("keeps the selected trigger label model-only", () => {
|
||||||
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
||||||
expect(buildSelectedTriggerLabel("Codex", "GPT-5.4")).toBe("GPT-5.4");
|
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -533,10 +533,22 @@ export function CombinedModelSelector({
|
|||||||
return { kind: "provider", providerId, providerLabel: label };
|
return { kind: "provider", providerId, providerLabel: label };
|
||||||
}, [allProviderModels, providerDefinitions]);
|
}, [allProviderModels, providerDefinitions]);
|
||||||
|
|
||||||
|
const computeInitialView = useCallback((): SelectorView => {
|
||||||
|
if (singleProviderView) return singleProviderView;
|
||||||
|
|
||||||
|
const selectedFavoriteKey = `${selectedProvider}:${selectedModel}`;
|
||||||
|
if (selectedProvider && selectedModel && !favoriteKeys.has(selectedFavoriteKey)) {
|
||||||
|
const label = resolveProviderLabel(providerDefinitions, selectedProvider);
|
||||||
|
return { kind: "provider", providerId: selectedProvider, providerLabel: label };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: "all" };
|
||||||
|
}, [singleProviderView, selectedProvider, selectedModel, favoriteKeys, providerDefinitions]);
|
||||||
|
|
||||||
const handleOpenChange = useCallback(
|
const handleOpenChange = useCallback(
|
||||||
(open: boolean) => {
|
(open: boolean) => {
|
||||||
setIsOpen(open);
|
setIsOpen(open);
|
||||||
setView(singleProviderView ?? { kind: "all" });
|
setView(computeInitialView());
|
||||||
if (open) {
|
if (open) {
|
||||||
onOpen?.();
|
onOpen?.();
|
||||||
} else {
|
} else {
|
||||||
@@ -544,33 +556,35 @@ export function CombinedModelSelector({
|
|||||||
onClose?.();
|
onClose?.();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onOpen, onClose, singleProviderView],
|
[onOpen, onClose, computeInitialView],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(provider: string, modelId: string) => {
|
(provider: string, modelId: string) => {
|
||||||
onSelect(provider as AgentProvider, modelId);
|
onSelect(provider as AgentProvider, modelId);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setView(singleProviderView ?? { kind: "all" });
|
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
},
|
},
|
||||||
[onSelect, singleProviderView],
|
[onSelect],
|
||||||
);
|
);
|
||||||
|
|
||||||
const ProviderIcon = getProviderIcon(selectedProvider);
|
const hasSelectedProvider = selectedProvider.trim().length > 0;
|
||||||
const selectedProviderLabel = useMemo(
|
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
|
||||||
() => resolveProviderLabel(providerDefinitions, selectedProvider),
|
|
||||||
[providerDefinitions, selectedProvider],
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedModelLabel = useMemo(() => {
|
const selectedModelLabel = useMemo(() => {
|
||||||
|
if (!selectedModel) {
|
||||||
|
if (!hasSelectedProvider) {
|
||||||
|
return "Select model";
|
||||||
|
}
|
||||||
|
return isLoading ? "Loading..." : "Select model";
|
||||||
|
}
|
||||||
const models = allProviderModels.get(selectedProvider);
|
const models = allProviderModels.get(selectedProvider);
|
||||||
if (!models) {
|
if (!models) {
|
||||||
return isLoading ? "Loading..." : "Select model";
|
return isLoading ? "Loading..." : "Select model";
|
||||||
}
|
}
|
||||||
const model = models.find((entry) => entry.id === selectedModel);
|
const model = models.find((entry) => entry.id === selectedModel);
|
||||||
return model?.label ?? resolveDefaultModelLabel(models);
|
return model?.label ?? resolveDefaultModelLabel(models);
|
||||||
}, [allProviderModels, isLoading, selectedModel, selectedProvider]);
|
}, [allProviderModels, hasSelectedProvider, isLoading, selectedModel, selectedProvider]);
|
||||||
|
|
||||||
const desktopFixedHeight = useMemo(() => {
|
const desktopFixedHeight = useMemo(() => {
|
||||||
if (view.kind !== "provider") {
|
if (view.kind !== "provider") {
|
||||||
@@ -586,8 +600,8 @@ export function CombinedModelSelector({
|
|||||||
return selectedModelLabel;
|
return selectedModelLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildSelectedTriggerLabel(selectedProviderLabel, selectedModelLabel);
|
return buildSelectedTriggerLabel(selectedModelLabel);
|
||||||
}, [selectedModelLabel, selectedProviderLabel]);
|
}, [selectedModelLabel]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (platformIsWeb) {
|
if (platformIsWeb) {
|
||||||
@@ -633,7 +647,9 @@ export function CombinedModelSelector({
|
|||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
{ProviderIcon ? (
|
||||||
|
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
) : null}
|
||||||
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
||||||
{triggerLabel}
|
{triggerLabel}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -647,7 +663,6 @@ export function CombinedModelSelector({
|
|||||||
onSelect={() => {}}
|
onSelect={() => {}}
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
onOpenChange={handleOpenChange}
|
onOpenChange={handleOpenChange}
|
||||||
stackBehavior="push"
|
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
desktopPlacement="top-start"
|
desktopPlacement="top-start"
|
||||||
desktopMinWidth={360}
|
desktopMinWidth={360}
|
||||||
@@ -788,11 +803,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
level2Header: {
|
level2Header: {},
|
||||||
backgroundColor: theme.colors.surface1,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: theme.colors.border,
|
|
||||||
},
|
|
||||||
backButton: {
|
backButton: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function resolveProviderLabel(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSelectedTriggerLabel(providerLabel: string, modelLabel: string): string {
|
export function buildSelectedTriggerLabel(modelLabel: string): string {
|
||||||
return modelLabel;
|
return modelLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
||||||
|
import type { AgentAttachment } from "@server/shared/messages";
|
||||||
|
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
|
||||||
|
|
||||||
|
export type ImageAttachment = AttachmentMetadata;
|
||||||
|
|
||||||
|
export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachment[]): {
|
||||||
|
images: ImageAttachment[];
|
||||||
|
attachments: AgentAttachment[];
|
||||||
|
} {
|
||||||
|
const images: ImageAttachment[] = [];
|
||||||
|
const reviewAttachments: AgentAttachment[] = [];
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
if (attachment.kind === "image") {
|
||||||
|
images.push(attachment.metadata);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewAttachment = buildGitHubAttachmentFromSearchItem(attachment.item);
|
||||||
|
if (reviewAttachment) {
|
||||||
|
reviewAttachments.push(reviewAttachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
images,
|
||||||
|
attachments: reviewAttachments,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./composer-height-mirror.native";
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { RefObject } from "react";
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
value: string;
|
||||||
|
textareaRef: RefObject<unknown>;
|
||||||
|
minHeight: number;
|
||||||
|
maxHeight: number;
|
||||||
|
onHeight: (height: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useComposerHeightMirror(_args: Args): void {
|
||||||
|
// No-op on native: onContentSizeChange drives height natively.
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
|
||||||
|
interface Args {
|
||||||
|
value: string;
|
||||||
|
textareaRef: RefObject<HTMLElement | null>;
|
||||||
|
minHeight: number;
|
||||||
|
maxHeight: number;
|
||||||
|
onHeight: (height: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COPIED_STYLES = [
|
||||||
|
"fontFamily",
|
||||||
|
"fontSize",
|
||||||
|
"fontWeight",
|
||||||
|
"fontStyle",
|
||||||
|
"fontVariant",
|
||||||
|
"lineHeight",
|
||||||
|
"letterSpacing",
|
||||||
|
"wordSpacing",
|
||||||
|
"textTransform",
|
||||||
|
"textIndent",
|
||||||
|
"whiteSpace",
|
||||||
|
"wordWrap",
|
||||||
|
"overflowWrap",
|
||||||
|
"wordBreak",
|
||||||
|
"tabSize",
|
||||||
|
"paddingTop",
|
||||||
|
"paddingRight",
|
||||||
|
"paddingBottom",
|
||||||
|
"paddingLeft",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function useComposerHeightMirror({
|
||||||
|
value,
|
||||||
|
textareaRef,
|
||||||
|
minHeight,
|
||||||
|
maxHeight,
|
||||||
|
onHeight,
|
||||||
|
}: Args): void {
|
||||||
|
const paramsRef = useRef({ value, minHeight, maxHeight, onHeight });
|
||||||
|
paramsRef.current = { value, minHeight, maxHeight, onHeight };
|
||||||
|
|
||||||
|
const mirrorRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const mirror = document.createElement("textarea");
|
||||||
|
mirror.setAttribute("aria-hidden", "true");
|
||||||
|
mirror.setAttribute("tabindex", "-1");
|
||||||
|
mirror.readOnly = true;
|
||||||
|
mirror.rows = 1;
|
||||||
|
const style = mirror.style;
|
||||||
|
style.position = "absolute";
|
||||||
|
style.top = "0";
|
||||||
|
style.left = "0";
|
||||||
|
style.visibility = "hidden";
|
||||||
|
style.pointerEvents = "none";
|
||||||
|
style.overflow = "hidden";
|
||||||
|
style.border = "0";
|
||||||
|
style.margin = "0";
|
||||||
|
style.resize = "none";
|
||||||
|
style.zIndex = "-1";
|
||||||
|
style.boxSizing = "border-box";
|
||||||
|
document.body.appendChild(mirror);
|
||||||
|
mirrorRef.current = mirror;
|
||||||
|
return () => {
|
||||||
|
mirror.remove();
|
||||||
|
mirrorRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const measure = useCallback(() => {
|
||||||
|
const mirror = mirrorRef.current;
|
||||||
|
const source = textareaRef.current;
|
||||||
|
if (!mirror || !source || typeof window === "undefined") return;
|
||||||
|
if (!(source instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const cs = window.getComputedStyle(source);
|
||||||
|
const ms = mirror.style;
|
||||||
|
for (const prop of COPIED_STYLES) {
|
||||||
|
ms[prop] = cs[prop];
|
||||||
|
}
|
||||||
|
ms.width = `${source.clientWidth}px`;
|
||||||
|
|
||||||
|
const { value, minHeight, maxHeight, onHeight } = paramsRef.current;
|
||||||
|
// Trailing newline is collapsed by textarea measurement — pad with a space.
|
||||||
|
mirror.value = value.endsWith("\n") ? `${value} ` : value;
|
||||||
|
|
||||||
|
const next = Math.max(minHeight, Math.min(maxHeight, mirror.scrollHeight));
|
||||||
|
onHeight(next);
|
||||||
|
}, [textareaRef]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
measure();
|
||||||
|
}, [value, measure]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const source = textareaRef.current;
|
||||||
|
if (!source || !(source instanceof HTMLElement)) return;
|
||||||
|
if (typeof ResizeObserver === "undefined") return;
|
||||||
|
const observer = new ResizeObserver(() => measure());
|
||||||
|
observer.observe(source);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [textareaRef, measure]);
|
||||||
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveStatusControlMode } from "./agent-input-area.status-controls";
|
import { resolveStatusControlMode } from "./composer.status-controls";
|
||||||
|
|
||||||
describe("resolveStatusControlMode", () => {
|
describe("resolveStatusControlMode", () => {
|
||||||
it("uses ready mode when no controlled status controls are provided", () => {
|
it("uses ready mode when no controlled status controls are provided", () => {
|
||||||
File diff suppressed because it is too large
Load Diff
+563
-203
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ type ContextWindowMeterProps = {
|
|||||||
usedTokens: number;
|
usedTokens: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SVG_SIZE = 20;
|
const SVG_SIZE = 16;
|
||||||
const CENTER = SVG_SIZE / 2;
|
const CENTER = SVG_SIZE / 2;
|
||||||
const RADIUS = 7;
|
const RADIUS = 7;
|
||||||
const STROKE_WIDTH = 2.25;
|
const STROKE_WIDTH = 2.25;
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { View, Text } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
|
||||||
|
interface DiffStatProps {
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compactFormatter = new Intl.NumberFormat("en-US", {
|
||||||
|
notation: "compact",
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function formatDiffCount(value: number): string {
|
||||||
|
return compactFormatter.format(value).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiffStat({ additions, deletions }: DiffStatProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Text style={styles.additions}>+{formatDiffCount(additions)}</Text>
|
||||||
|
<Text style={styles.deletions}>-{formatDiffCount(deletions)}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
row: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
additions: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
color: theme.colors.diffAddition,
|
||||||
|
},
|
||||||
|
deletions: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
color: theme.colors.diffDeletion,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -142,7 +142,7 @@ export function DraggableList<T>({
|
|||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
distance: 8,
|
distance: 6,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
useSensor(KeyboardSensor, {
|
useSensor(KeyboardSensor, {
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-nativ
|
|||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { X } from "lucide-react-native";
|
import { X } from "lucide-react-native";
|
||||||
|
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||||
|
import { PrPane } from "./pr-pane";
|
||||||
|
import { usePrPaneData } from "@/hooks/use-pr-pane-data";
|
||||||
import {
|
import {
|
||||||
usePanelStore,
|
usePanelStore,
|
||||||
|
selectIsFileExplorerOpen,
|
||||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||||
type ExplorerTab,
|
type ExplorerTab,
|
||||||
@@ -49,9 +53,9 @@ export function ExplorerSidebar({
|
|||||||
const isScreenFocused = useIsFocused();
|
const isScreenFocused = useIsFocused();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile }));
|
||||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||||
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
||||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||||
@@ -78,9 +82,6 @@ export function ExplorerSidebar({
|
|||||||
}
|
}
|
||||||
}, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]);
|
}, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]);
|
||||||
|
|
||||||
// Derive isOpen from the unified panel state
|
|
||||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
@@ -101,18 +102,20 @@ export function ExplorerSidebar({
|
|||||||
logExplorerSidebar("handleClose", {
|
logExplorerSidebar("handleClose", {
|
||||||
reason,
|
reason,
|
||||||
isOpen,
|
isOpen,
|
||||||
mobileView,
|
|
||||||
desktopFileExplorerOpen,
|
|
||||||
});
|
});
|
||||||
closeToAgent();
|
if (isMobile) {
|
||||||
|
showMobileAgent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeDesktopFileExplorer();
|
||||||
},
|
},
|
||||||
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
|
[closeDesktopFileExplorer, isMobile, isOpen, showMobileAgent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCloseFromGesture = useCallback(() => {
|
const handleCloseFromGesture = useCallback(() => {
|
||||||
gestureAnimatingRef.current = true;
|
gestureAnimatingRef.current = true;
|
||||||
closeToAgent();
|
showMobileAgent();
|
||||||
}, [closeToAgent, gestureAnimatingRef]);
|
}, [gestureAnimatingRef, showMobileAgent]);
|
||||||
|
|
||||||
const enableSidebarCloseGesture = isMobile && isOpen;
|
const enableSidebarCloseGesture = isMobile && isOpen;
|
||||||
|
|
||||||
@@ -289,6 +292,7 @@ export function ExplorerSidebar({
|
|||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
isGit={isGit}
|
isGit={isGit}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
isOpen={isOpen}
|
||||||
onOpenFile={onOpenFile}
|
onOpenFile={onOpenFile}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -321,6 +325,7 @@ export function ExplorerSidebar({
|
|||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
isGit={isGit}
|
isGit={isGit}
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
|
isOpen={isOpen}
|
||||||
onOpenFile={onOpenFile}
|
onOpenFile={onOpenFile}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -337,6 +342,7 @@ interface SidebarContentProps {
|
|||||||
workspaceRoot: string;
|
workspaceRoot: string;
|
||||||
isGit: boolean;
|
isGit: boolean;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
|
isOpen: boolean;
|
||||||
onOpenFile?: (filePath: string) => void;
|
onOpenFile?: (filePath: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,11 +355,24 @@ function SidebarContent({
|
|||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
isGit,
|
isGit,
|
||||||
isMobile,
|
isMobile,
|
||||||
|
isOpen,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
}: SidebarContentProps) {
|
}: SidebarContentProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const padding = useWindowControlsPadding("explorerSidebar");
|
const padding = useWindowControlsPadding("explorerSidebar");
|
||||||
const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab;
|
const canQueryPullRequest = isGit && Boolean(workspaceRoot);
|
||||||
|
const prPane = usePrPaneData({
|
||||||
|
serverId,
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
enabled: canQueryPullRequest && isOpen,
|
||||||
|
timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen,
|
||||||
|
});
|
||||||
|
const hasPullRequest = prPane.prNumber !== null;
|
||||||
|
const requestedTab: ExplorerTab =
|
||||||
|
!isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab;
|
||||||
|
const resolvedTab: ExplorerTab =
|
||||||
|
requestedTab === "pr" && !hasPullRequest ? "changes" : requestedTab;
|
||||||
|
const prTabLabel = prPane.prNumber === null ? "" : `#${prPane.prNumber}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||||
@@ -381,6 +400,23 @@ function SidebarContent({
|
|||||||
Files
|
Files
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
{isGit && hasPullRequest && (
|
||||||
|
<Pressable
|
||||||
|
testID="explorer-tab-pr"
|
||||||
|
style={[styles.tab, resolvedTab === "pr" && styles.tabActive]}
|
||||||
|
onPress={() => onTabPress("pr")}
|
||||||
|
>
|
||||||
|
<GitHubIcon
|
||||||
|
size={13}
|
||||||
|
color={
|
||||||
|
resolvedTab === "pr" ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.tabText, resolvedTab === "pr" && styles.tabTextActive]}>
|
||||||
|
{prTabLabel}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.headerRightSection}>
|
<View style={styles.headerRightSection}>
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
@@ -409,6 +445,7 @@ function SidebarContent({
|
|||||||
onOpenFile={onOpenFile}
|
onOpenFile={onOpenFile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{resolvedTab === "pr" && prPane.data && <PrPane data={prPane.data} />}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,18 +8,8 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Gesture } from "react-native-gesture-handler";
|
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import Animated, {
|
|
||||||
cancelAnimation,
|
|
||||||
Easing,
|
|
||||||
runOnJS,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withRepeat,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
@@ -34,6 +24,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||||
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
@@ -195,35 +186,6 @@ export function FileExplorerPane({
|
|||||||
});
|
});
|
||||||
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
|
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
|
||||||
|
|
||||||
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedEntryPath || !workspaceStateKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const parentDir = getParentDirectory(selectedEntryPath);
|
|
||||||
const ancestors = getAncestorDirectories(parentDir);
|
|
||||||
const newPaths = ancestors.filter((path) => !expandedPaths.has(path));
|
|
||||||
if (newPaths.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), ...newPaths]);
|
|
||||||
newPaths.forEach((path) => {
|
|
||||||
if (!directories.has(path)) {
|
|
||||||
void requestDirectoryListing(path, {
|
|
||||||
recordHistory: false,
|
|
||||||
setCurrentPath: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
directories,
|
|
||||||
workspaceStateKey,
|
|
||||||
expandedPaths,
|
|
||||||
requestDirectoryListing,
|
|
||||||
selectedEntryPath,
|
|
||||||
setExpandedPathsForWorkspace,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleToggleDirectory = useCallback(
|
const handleToggleDirectory = useCallback(
|
||||||
(entry: ExplorerEntry) => {
|
(entry: ExplorerEntry) => {
|
||||||
if (!workspaceStateKey) {
|
if (!workspaceStateKey) {
|
||||||
@@ -341,48 +303,6 @@ export function FileExplorerPane({
|
|||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
void refetchExplorer();
|
void refetchExplorer();
|
||||||
}, [refetchExplorer]);
|
}, [refetchExplorer]);
|
||||||
const refreshIconRotation = useSharedValue(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isRefreshFetching) {
|
|
||||||
refreshIconRotation.value = 0;
|
|
||||||
refreshIconRotation.value = withRepeat(
|
|
||||||
withTiming(360, {
|
|
||||||
duration: 700,
|
|
||||||
easing: Easing.linear,
|
|
||||||
}),
|
|
||||||
-1,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelAnimation(refreshIconRotation);
|
|
||||||
const remainder = refreshIconRotation.value % 360;
|
|
||||||
if (Math.abs(remainder) < 0.001) {
|
|
||||||
refreshIconRotation.value = 0;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remaining = 360 - remainder;
|
|
||||||
const duration = Math.max(80, Math.round((remaining / 360) * 700));
|
|
||||||
refreshIconRotation.value = withTiming(
|
|
||||||
360,
|
|
||||||
{
|
|
||||||
duration,
|
|
||||||
easing: Easing.linear,
|
|
||||||
},
|
|
||||||
(finished) => {
|
|
||||||
if (finished) {
|
|
||||||
refreshIconRotation.value = 0;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}, [isRefreshFetching, refreshIconRotation]);
|
|
||||||
|
|
||||||
const refreshIconAnimatedStyle = useAnimatedStyle(() => ({
|
|
||||||
transform: [{ rotate: `${refreshIconRotation.value}deg` }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
|
const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
|
||||||
|
|
||||||
@@ -592,11 +512,15 @@ export function FileExplorerPane({
|
|||||||
(hovered || pressed) && styles.iconButtonHovered,
|
(hovered || pressed) && styles.iconButtonHovered,
|
||||||
]}
|
]}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Refresh files"
|
accessibilityLabel={isRefreshFetching ? "Refreshing files" : "Refresh files"}
|
||||||
>
|
>
|
||||||
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
<View style={styles.refreshIcon}>
|
||||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
{isRefreshFetching ? (
|
||||||
</Animated.View>
|
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
) : (
|
||||||
|
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<FlatList
|
<FlatList
|
||||||
@@ -682,35 +606,6 @@ function buildTreeRows({
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getParentDirectory(path: string): string {
|
|
||||||
const normalized = path.replace(/\/+$/, "");
|
|
||||||
if (!normalized || normalized === ".") {
|
|
||||||
return ".";
|
|
||||||
}
|
|
||||||
const lastSlash = normalized.lastIndexOf("/");
|
|
||||||
if (lastSlash === -1) {
|
|
||||||
return ".";
|
|
||||||
}
|
|
||||||
const dir = normalized.slice(0, lastSlash);
|
|
||||||
return dir.length > 0 ? dir : ".";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAncestorDirectories(directory: string): string[] {
|
|
||||||
const trimmed = directory.replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
||||||
if (!trimmed || trimmed === ".") {
|
|
||||||
return ["."];
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = trimmed.split("/").filter(Boolean);
|
|
||||||
const ancestors: string[] = ["."];
|
|
||||||
let acc = "";
|
|
||||||
for (const part of parts) {
|
|
||||||
acc = acc ? `${acc}/${part}` : part;
|
|
||||||
ancestors.push(acc);
|
|
||||||
}
|
|
||||||
return ancestors;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string {
|
function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string {
|
||||||
if (!state) {
|
if (!state) {
|
||||||
return ".";
|
return ".";
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ import type { GitAction, GitActions } from "@/components/git-actions-policy";
|
|||||||
|
|
||||||
interface GitActionsSplitButtonProps {
|
interface GitActionsSplitButtonProps {
|
||||||
gitActions: GitActions;
|
gitActions: GitActions;
|
||||||
|
hideLabels?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
|
export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSplitButtonProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||||
@@ -68,9 +69,11 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
|||||||
) : (
|
) : (
|
||||||
<View style={styles.splitButtonContent}>
|
<View style={styles.splitButtonContent}>
|
||||||
{gitActions.primary.icon}
|
{gitActions.primary.icon}
|
||||||
<Text style={styles.splitButtonText}>
|
{!hideLabels && (
|
||||||
{getActionDisplayLabel(gitActions.primary)}
|
<Text style={styles.splitButtonText}>
|
||||||
</Text>
|
{getActionDisplayLabel(gitActions.primary)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useCallback, useEffect, useMemo, useRef, memo, type ReactElement } from "react";
|
import { useState, useCallback, useEffect, useMemo, useRef, memo, type ReactElement } from "react";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -488,8 +489,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.fileHeaderRight}>
|
<View style={styles.fileHeaderRight}>
|
||||||
<Text style={styles.additions}>+{file.additions}</Text>
|
<DiffStat additions={file.additions} deletions={file.deletions} />
|
||||||
<Text style={styles.deletions}>-{file.deletions}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
@@ -661,10 +661,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
const {
|
const {
|
||||||
status,
|
status,
|
||||||
isLoading: isStatusLoading,
|
isLoading: isStatusLoading,
|
||||||
isFetching: isStatusFetching,
|
|
||||||
isError: isStatusError,
|
isError: isStatusError,
|
||||||
error: statusError,
|
error: statusError,
|
||||||
refresh: refreshStatus,
|
|
||||||
} = useCheckoutStatusQuery({ serverId, cwd });
|
} = useCheckoutStatusQuery({ serverId, cwd });
|
||||||
const gitStatus = status && status.isGit ? status : null;
|
const gitStatus = status && status.isGit ? status : null;
|
||||||
const isGit = Boolean(gitStatus);
|
const isGit = Boolean(gitStatus);
|
||||||
@@ -683,10 +681,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
files,
|
files,
|
||||||
payloadError: diffPayloadError,
|
payloadError: diffPayloadError,
|
||||||
isLoading: isDiffLoading,
|
isLoading: isDiffLoading,
|
||||||
isFetching: isDiffFetching,
|
|
||||||
isError: isDiffError,
|
|
||||||
error: diffError,
|
|
||||||
refresh: refreshDiff,
|
|
||||||
} = useCheckoutDiffQuery({
|
} = useCheckoutDiffQuery({
|
||||||
serverId,
|
serverId,
|
||||||
cwd,
|
cwd,
|
||||||
@@ -699,14 +693,11 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
status: prStatus,
|
status: prStatus,
|
||||||
githubFeaturesEnabled,
|
githubFeaturesEnabled,
|
||||||
payloadError: prPayloadError,
|
payloadError: prPayloadError,
|
||||||
refresh: refreshPrStatus,
|
|
||||||
} = useCheckoutPrStatusQuery({
|
} = useCheckoutPrStatusQuery({
|
||||||
serverId,
|
serverId,
|
||||||
cwd,
|
cwd,
|
||||||
enabled: isGit,
|
enabled: isGit,
|
||||||
});
|
});
|
||||||
// Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches
|
|
||||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
|
||||||
const normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]);
|
const normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]);
|
||||||
const workspaceStateKey = useMemo(
|
const workspaceStateKey = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -732,13 +723,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
const headerHeightByPathRef = useRef<Record<string, number>>({});
|
const headerHeightByPathRef = useRef<Record<string, number>>({});
|
||||||
const bodyHeightByPathRef = useRef<Record<string, number>>({});
|
const bodyHeightByPathRef = useRef<Record<string, number>>({});
|
||||||
const defaultHeaderHeightRef = useRef<number>(44);
|
const defaultHeaderHeightRef = useRef<number>(44);
|
||||||
const handleRefresh = useCallback(() => {
|
|
||||||
setIsManualRefresh(true);
|
|
||||||
void refreshDiff();
|
|
||||||
void refreshStatus();
|
|
||||||
void refreshPrStatus();
|
|
||||||
}, [refreshDiff, refreshStatus, refreshPrStatus]);
|
|
||||||
|
|
||||||
const shipDefaultStorageKey = useMemo(() => {
|
const shipDefaultStorageKey = useMemo(() => {
|
||||||
if (!gitStatus?.repoRoot) {
|
if (!gitStatus?.repoRoot) {
|
||||||
return null;
|
return null;
|
||||||
@@ -901,13 +885,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
}
|
}
|
||||||
}, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
|
}, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
|
||||||
|
|
||||||
// Reset manual refresh flag when fetch completes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!(isDiffFetching || isStatusFetching) && isManualRefresh) {
|
|
||||||
setIsManualRefresh(false);
|
|
||||||
}
|
|
||||||
}, [isDiffFetching, isStatusFetching, isManualRefresh]);
|
|
||||||
|
|
||||||
// Clear diff mode override when auto mode changes (e.g., after commit)
|
// Clear diff mode override when auto mode changes (e.g., after commit)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDiffModeOverride(null);
|
setDiffModeOverride(null);
|
||||||
@@ -1092,9 +1069,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
);
|
);
|
||||||
|
|
||||||
const hasChanges = files.length > 0;
|
const hasChanges = files.length > 0;
|
||||||
const diffErrorMessage =
|
const diffErrorMessage = diffPayloadError?.message ?? null;
|
||||||
diffPayloadError?.message ??
|
|
||||||
(isDiffError && diffError instanceof Error ? diffError.message : null);
|
|
||||||
const prErrorMessage = githubFeaturesEnabled ? (prPayloadError?.message ?? null) : null;
|
const prErrorMessage = githubFeaturesEnabled ? (prPayloadError?.message ?? null) : null;
|
||||||
const branchLabel =
|
const branchLabel =
|
||||||
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
|
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
|
||||||
@@ -1199,8 +1174,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||||
onRefresh={handleRefresh}
|
|
||||||
refreshing={isManualRefresh && isDiffFetching}
|
|
||||||
// Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.
|
// Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.
|
||||||
// Keep a larger render window and disable clipping to avoid bodies disappearing mid-scroll.
|
// Keep a larger render window and disable clipping to avoid bodies disappearing mid-scroll.
|
||||||
removeClippedSubviews={false}
|
removeClippedSubviews={false}
|
||||||
|
|||||||
@@ -1,31 +1,35 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Pressable, Text } from "react-native";
|
import { Pressable } from "react-native";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ArrowLeft } from "lucide-react-native";
|
import { ArrowLeft } from "lucide-react-native";
|
||||||
import { ScreenHeader } from "./screen-header";
|
import { ScreenHeader } from "./screen-header";
|
||||||
|
import { ScreenTitle } from "./screen-title";
|
||||||
|
|
||||||
interface BackHeaderProps {
|
interface BackHeaderProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
titleAccessory?: ReactNode;
|
||||||
rightContent?: ReactNode;
|
rightContent?: ReactNode;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
|
export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
left={
|
left={
|
||||||
<>
|
<>
|
||||||
<Pressable onPress={onBack ?? (() => router.back())} style={styles.backButton}>
|
<Pressable
|
||||||
|
onPress={onBack ?? (() => router.back())}
|
||||||
|
style={styles.backButton}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Back"
|
||||||
|
>
|
||||||
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{title && (
|
{title && <ScreenTitle>{title}</ScreenTitle>}
|
||||||
<Text style={styles.title} numberOfLines={1}>
|
{titleAccessory}
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
right={rightContent}
|
right={rightContent}
|
||||||
@@ -45,13 +49,4 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
},
|
},
|
||||||
title: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: theme.fontSize.lg,
|
|
||||||
fontWeight: {
|
|
||||||
xs: theme.fontWeight.semibold,
|
|
||||||
md: "400",
|
|
||||||
},
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { headerIconSlotStyle } from "./header-toggle-button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-interactive icon slot sitting at the start of a screen header's left
|
||||||
|
* cluster. Shares the same padding + border-radius as `HeaderToggleButton` so
|
||||||
|
* decorative headers (settings sections, host detail) line up with the sidebar
|
||||||
|
* toggle across screens.
|
||||||
|
*/
|
||||||
|
export function HeaderIconBadge({ children }: { children: ReactNode }) {
|
||||||
|
return <View style={headerIconSlotStyle.slot}>{children}</View>;
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ export function HeaderToggleButton({
|
|||||||
onPress={(e) => {
|
onPress={(e) => {
|
||||||
onPress(e);
|
onPress(e);
|
||||||
}}
|
}}
|
||||||
style={[styles.button, style]}
|
style={[headerIconSlotStyle.slot, style]}
|
||||||
>
|
>
|
||||||
{typeof children === "function"
|
{typeof children === "function"
|
||||||
? (state: { pressed: boolean; hovered?: boolean }) =>
|
? (state: { pressed: boolean; hovered?: boolean }) =>
|
||||||
@@ -68,14 +68,17 @@ export function HeaderToggleButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
export const headerIconSlotStyle = StyleSheet.create((theme) => ({
|
||||||
button: {
|
slot: {
|
||||||
padding: {
|
padding: {
|
||||||
xs: theme.spacing[3],
|
xs: theme.spacing[3],
|
||||||
md: theme.spacing[2],
|
md: theme.spacing[2],
|
||||||
},
|
},
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
},
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
tooltipRow: {
|
tooltipRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
|
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { PanelLeft } from "lucide-react-native";
|
import { PanelLeft } from "lucide-react-native";
|
||||||
import { ScreenHeader } from "./screen-header";
|
import { ScreenHeader } from "./screen-header";
|
||||||
|
import { ScreenTitle } from "./screen-title";
|
||||||
import { HeaderToggleButton } from "./header-toggle-button";
|
import { HeaderToggleButton } from "./header-toggle-button";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
|
|
||||||
@@ -45,18 +46,16 @@ export function SidebarMenuToggle({
|
|||||||
}: SidebarMenuToggleProps = {}) {
|
}: SidebarMenuToggleProps = {}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
|
||||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
|
||||||
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
|
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
|
||||||
|
|
||||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
|
||||||
const menuIconColor =
|
const menuIconColor =
|
||||||
!isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted;
|
!isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeaderToggleButton
|
<HeaderToggleButton
|
||||||
onPress={toggleAgentList}
|
onPress={() => toggleAgentListForLayout({ isCompact: isMobile })}
|
||||||
tooltipLabel="Toggle sidebar"
|
tooltipLabel="Toggle sidebar"
|
||||||
tooltipKeys={toggleShortcutKeys}
|
tooltipKeys={toggleShortcutKeys}
|
||||||
tooltipSide={tooltipSide}
|
tooltipSide={tooltipSide}
|
||||||
@@ -83,11 +82,7 @@ export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps)
|
|||||||
left={
|
left={
|
||||||
<>
|
<>
|
||||||
<SidebarMenuToggle />
|
<SidebarMenuToggle />
|
||||||
{title && (
|
{title && <ScreenTitle>{title}</ScreenTitle>}
|
||||||
<Text style={styles.title} numberOfLines={1}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
right={rightContent}
|
right={rightContent}
|
||||||
@@ -101,15 +96,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
left: {
|
left: {
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
title: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
fontWeight: {
|
|
||||||
xs: "400",
|
|
||||||
md: "300",
|
|
||||||
},
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
},
|
|
||||||
mobileMenuIcon: {
|
mobileMenuIcon: {
|
||||||
width: MOBILE_MENU_LINE_WIDTH,
|
width: MOBILE_MENU_LINE_WIDTH,
|
||||||
height: 12,
|
height: 12,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import type { LayoutChangeEvent } from "react-native";
|
||||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -17,6 +18,7 @@ interface ScreenHeaderProps {
|
|||||||
leftStyle?: StyleProp<ViewStyle>;
|
leftStyle?: StyleProp<ViewStyle>;
|
||||||
rightStyle?: StyleProp<ViewStyle>;
|
rightStyle?: StyleProp<ViewStyle>;
|
||||||
borderless?: boolean;
|
borderless?: boolean;
|
||||||
|
onRowLayout?: (event: LayoutChangeEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,6 +31,7 @@ export function ScreenHeader({
|
|||||||
leftStyle,
|
leftStyle,
|
||||||
rightStyle,
|
rightStyle,
|
||||||
borderless,
|
borderless,
|
||||||
|
onRowLayout,
|
||||||
}: ScreenHeaderProps) {
|
}: ScreenHeaderProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -42,6 +45,7 @@ export function ScreenHeader({
|
|||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
|
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
|
||||||
<View
|
<View
|
||||||
|
onLayout={onRowLayout}
|
||||||
style={[
|
style={[
|
||||||
styles.row,
|
styles.row,
|
||||||
{
|
{
|
||||||
@@ -84,11 +88,13 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
right: {
|
right: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
borderless: {
|
borderless: {
|
||||||
borderBottomColor: "transparent",
|
borderBottomColor: "transparent",
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Text, type StyleProp, type TextStyle } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
|
||||||
|
interface ScreenTitleProps {
|
||||||
|
children: ReactNode;
|
||||||
|
numberOfLines?: number;
|
||||||
|
testID?: string;
|
||||||
|
style?: StyleProp<TextStyle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical screen title for use inside `ScreenHeader`. One typography, one
|
||||||
|
* color, responsive weight. Leading icons are siblings (HeaderToggleButton,
|
||||||
|
* HeaderIconBadge) — never nested inside this component.
|
||||||
|
*/
|
||||||
|
export function ScreenTitle({ children, numberOfLines = 1, testID, style }: ScreenTitleProps) {
|
||||||
|
return (
|
||||||
|
<Text style={[styles.text, style]} numberOfLines={numberOfLines} testID={testID}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
text: {
|
||||||
|
flexShrink: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: {
|
||||||
|
xs: "400",
|
||||||
|
md: "300",
|
||||||
|
},
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { act } from "@testing-library/react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { panelState, useSidebarWorkspacesListMock, theme } = vi.hoisted(() => {
|
||||||
|
const panelState = {
|
||||||
|
isOpen: false,
|
||||||
|
showMobileAgent: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
panelState,
|
||||||
|
useSidebarWorkspacesListMock: vi.fn(),
|
||||||
|
theme: {
|
||||||
|
spacing: { 0: 0, 0.5: 2, 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16, 5: 20 },
|
||||||
|
iconSize: { sm: 14, md: 18, lg: 22 },
|
||||||
|
borderWidth: { 1: 1 },
|
||||||
|
borderRadius: { sm: 4, md: 6, lg: 8, full: 999 },
|
||||||
|
fontSize: { xs: 11, sm: 13, base: 15 },
|
||||||
|
fontWeight: { normal: "400", medium: "500", semibold: "600" },
|
||||||
|
colors: {
|
||||||
|
surfaceSidebar: "#111",
|
||||||
|
surface1: "#111",
|
||||||
|
surface2: "#222",
|
||||||
|
surface3: "#333",
|
||||||
|
surface4: "#444",
|
||||||
|
foreground: "#fff",
|
||||||
|
foregroundMuted: "#aaa",
|
||||||
|
border: "#555",
|
||||||
|
borderAccent: "#666",
|
||||||
|
accent: "#0a84ff",
|
||||||
|
accentForeground: "#fff",
|
||||||
|
palette: {
|
||||||
|
green: { 400: "#30d158" },
|
||||||
|
amber: { 500: "#ffd60a" },
|
||||||
|
red: { 500: "#ff453a" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-native-unistyles", () => ({
|
||||||
|
StyleSheet: {
|
||||||
|
absoluteFillObject: {},
|
||||||
|
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||||
|
},
|
||||||
|
useUnistyles: () => ({ theme }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-reanimated", () => ({
|
||||||
|
default: {
|
||||||
|
View: "div",
|
||||||
|
},
|
||||||
|
Extrapolation: { CLAMP: "clamp" },
|
||||||
|
interpolate: () => 0,
|
||||||
|
runOnJS: (fn: (...args: unknown[]) => unknown) => fn,
|
||||||
|
useAnimatedStyle: (factory: () => unknown) => factory(),
|
||||||
|
useSharedValue: (value: unknown) => ({ value }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-gesture-handler", () => {
|
||||||
|
const chain = {
|
||||||
|
enabled: () => chain,
|
||||||
|
hitSlop: () => chain,
|
||||||
|
manualActivation: () => chain,
|
||||||
|
onTouchesDown: () => chain,
|
||||||
|
onTouchesMove: () => chain,
|
||||||
|
onStart: () => chain,
|
||||||
|
onUpdate: () => chain,
|
||||||
|
onEnd: () => chain,
|
||||||
|
onFinalize: () => chain,
|
||||||
|
withRef: () => chain,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
Gesture: { Pan: () => chain },
|
||||||
|
GestureDetector: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("lucide-react-native", () => {
|
||||||
|
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||||
|
React.createElement("span", { ...props, "data-icon": name });
|
||||||
|
return {
|
||||||
|
MessagesSquare: createIcon("MessagesSquare"),
|
||||||
|
Plus: createIcon("Plus"),
|
||||||
|
Settings: createIcon("Settings"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("expo-router", () => ({
|
||||||
|
router: { push: vi.fn() },
|
||||||
|
usePathname: () => "/hosts/srv",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-safe-area-context", () => ({
|
||||||
|
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/layout", () => ({
|
||||||
|
useIsCompactFormFactor: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/platform", () => ({
|
||||||
|
isWeb: true,
|
||||||
|
isNative: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/panel-store", () => ({
|
||||||
|
MIN_SIDEBAR_WIDTH: 260,
|
||||||
|
MAX_SIDEBAR_WIDTH: 420,
|
||||||
|
selectIsAgentListOpen: (state: typeof panelState) => state.isOpen,
|
||||||
|
usePanelStore: (selector: (state: typeof panelState) => unknown) => selector(panelState),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/runtime/host-runtime", () => ({
|
||||||
|
useHosts: () => [{ serverId: "srv", label: "Local" }],
|
||||||
|
useHostRuntimeSnapshot: () => ({ connectionStatus: "online" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-sidebar-workspaces-list", () => ({
|
||||||
|
useSidebarWorkspacesList: useSidebarWorkspacesListMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-sidebar-shortcut-model", () => ({
|
||||||
|
useSidebarShortcutModel: () => ({
|
||||||
|
collapsedProjectKeys: new Set<string>(),
|
||||||
|
shortcutIndexByWorkspaceKey: new Map<string, number>(),
|
||||||
|
toggleProjectCollapsed: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/contexts/sidebar-animation-context", () => ({
|
||||||
|
useSidebarAnimation: () => ({
|
||||||
|
translateX: { value: 0 },
|
||||||
|
backdropOpacity: { value: 0 },
|
||||||
|
windowWidth: 390,
|
||||||
|
animateToOpen: vi.fn(),
|
||||||
|
animateToClose: vi.fn(),
|
||||||
|
isGesturing: { value: false },
|
||||||
|
gestureAnimatingRef: { current: false },
|
||||||
|
closeGestureRef: { current: undefined },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-shortcut-keys", () => ({
|
||||||
|
useShortcutKeys: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/utils/desktop-window", () => ({
|
||||||
|
useWindowControlsPadding: () => ({ top: 0 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/utils/host-routes", () => ({
|
||||||
|
buildHostSessionsRoute: (serverId: string) => `/hosts/${serverId}/sessions`,
|
||||||
|
buildSettingsRoute: () => "/settings",
|
||||||
|
mapPathnameToServer: (_pathname: string, serverId: string) => `/hosts/${serverId}`,
|
||||||
|
parseServerIdFromPathname: () => "srv",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-open-project-picker", () => ({
|
||||||
|
useOpenProjectPicker: () => vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/sidebar/sidebar-header-row", () => ({
|
||||||
|
SidebarHeaderRow: ({ label }: { label: string }) => React.createElement("div", null, label),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./sidebar-workspace-list", () => ({
|
||||||
|
SidebarWorkspaceList: ({ projects }: { projects: Array<{ projectName: string }> }) =>
|
||||||
|
React.createElement(
|
||||||
|
"div",
|
||||||
|
{ "data-testid": "sidebar-workspace-list" },
|
||||||
|
projects.map((project) => project.projectName).join(","),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./sidebar-agent-list-skeleton", () => ({
|
||||||
|
SidebarAgentListSkeleton: () => React.createElement("div", null, "Loading"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/tooltip", () => ({
|
||||||
|
Tooltip: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement("div", null, children),
|
||||||
|
TooltipContent: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement("div", null, children),
|
||||||
|
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
React.createElement("div", null, children),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/shortcut", () => ({
|
||||||
|
Shortcut: () => React.createElement("span", null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/combobox", () => ({
|
||||||
|
Combobox: () => null,
|
||||||
|
ComboboxItem: ({ label }: { label: string }) => React.createElement("div", null, label),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.stubGlobal("React", React);
|
||||||
|
|
||||||
|
import { LeftSidebar } from "./left-sidebar";
|
||||||
|
|
||||||
|
describe("LeftSidebar", () => {
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLElement | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
panelState.isOpen = false;
|
||||||
|
panelState.showMobileAgent.mockReset();
|
||||||
|
useSidebarWorkspacesListMock.mockReset();
|
||||||
|
useSidebarWorkspacesListMock.mockReturnValue({
|
||||||
|
projects: [{ projectKey: "project-1", projectName: "Project 1", workspaces: [] }],
|
||||||
|
isInitialLoad: false,
|
||||||
|
isRevalidating: false,
|
||||||
|
refreshAll: vi.fn(),
|
||||||
|
});
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root = null;
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the mobile workspace list subscribed while the sidebar is hidden", async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<LeftSidebar />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useSidebarWorkspacesListMock).toHaveBeenLastCalledWith({
|
||||||
|
serverId: "srv",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -32,7 +32,13 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { router, usePathname } from "expo-router";
|
import { router, usePathname } from "expo-router";
|
||||||
import { usePanelStore, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH } from "@/stores/panel-store";
|
import {
|
||||||
|
usePanelStore,
|
||||||
|
selectIsAgentListOpen,
|
||||||
|
MIN_SIDEBAR_WIDTH,
|
||||||
|
MAX_SIDEBAR_WIDTH,
|
||||||
|
} from "@/stores/panel-store";
|
||||||
|
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||||
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
||||||
@@ -46,19 +52,15 @@ import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
|||||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||||
import { formatConnectionStatus } from "@/utils/daemons";
|
import { formatConnectionStatus } from "@/utils/daemons";
|
||||||
import {
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
HEADER_INNER_HEIGHT,
|
|
||||||
HEADER_INNER_HEIGHT_MOBILE,
|
|
||||||
useIsCompactFormFactor,
|
|
||||||
} from "@/constants/layout";
|
|
||||||
import {
|
import {
|
||||||
buildHostSessionsRoute,
|
buildHostSessionsRoute,
|
||||||
buildHostSettingsRoute,
|
buildSettingsRoute,
|
||||||
mapPathnameToServer,
|
mapPathnameToServer,
|
||||||
parseServerIdFromPathname,
|
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import { resolveActiveHost } from "@/utils/active-host";
|
||||||
|
|
||||||
const MIN_CHAT_WIDTH = 400;
|
const MIN_CHAT_WIDTH = 400;
|
||||||
|
|
||||||
@@ -119,24 +121,16 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
const mobileView = usePanelStore((state) => state.mobileView);
|
const isOpen = usePanelStore((state) =>
|
||||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
selectIsAgentListOpen(state, { isCompact: isCompactLayout }),
|
||||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
);
|
||||||
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
const activeDaemon = useMemo(
|
||||||
const activeDaemon = useMemo(() => {
|
() => resolveActiveHost({ hosts: daemons, pathname }),
|
||||||
if (daemons.length === 0) {
|
[daemons, pathname],
|
||||||
return null;
|
);
|
||||||
}
|
|
||||||
if (activeServerIdFromPath) {
|
|
||||||
const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath);
|
|
||||||
if (routeMatch) {
|
|
||||||
return routeMatch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return daemons[0] ?? null;
|
|
||||||
}, [activeServerIdFromPath, daemons]);
|
|
||||||
const activeServerId = activeDaemon?.serverId ?? null;
|
const activeServerId = activeDaemon?.serverId ?? null;
|
||||||
const activeHostLabel = useMemo(() => {
|
const activeHostLabel = useMemo(() => {
|
||||||
if (!activeDaemon) return "No host";
|
if (!activeDaemon) return "No host";
|
||||||
@@ -186,14 +180,12 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
const hostTriggerRef = useRef<View | null>(null);
|
const hostTriggerRef = useRef<View | null>(null);
|
||||||
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
const [isHostPickerOpen, setIsHostPickerOpen] = useState(false);
|
||||||
|
|
||||||
const isOpen = isCompactLayout ? mobileView === "agent-list" : desktopAgentListOpen;
|
|
||||||
|
|
||||||
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({
|
||||||
serverId: activeServerId,
|
serverId: activeServerId,
|
||||||
enabled: isOpen,
|
enabled: isCompactLayout || isOpen,
|
||||||
});
|
});
|
||||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||||
useSidebarShortcutModel(projects);
|
useSidebarShortcutModel({ projects, isInitialLoad });
|
||||||
|
|
||||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||||
|
|
||||||
@@ -211,28 +203,22 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
const openProjectPicker = useOpenProjectPicker(activeServerId);
|
const openProjectPicker = useOpenProjectPicker(activeServerId);
|
||||||
|
|
||||||
const handleOpenProjectMobile = useCallback(() => {
|
const handleOpenProjectMobile = useCallback(() => {
|
||||||
closeToAgent();
|
showMobileAgent();
|
||||||
void openProjectPicker();
|
void openProjectPicker();
|
||||||
}, [closeToAgent, openProjectPicker]);
|
}, [showMobileAgent, openProjectPicker]);
|
||||||
|
|
||||||
const handleOpenProjectDesktop = useCallback(() => {
|
const handleOpenProjectDesktop = useCallback(() => {
|
||||||
void openProjectPicker();
|
void openProjectPicker();
|
||||||
}, [openProjectPicker]);
|
}, [openProjectPicker]);
|
||||||
|
|
||||||
const handleSettingsMobile = useCallback(() => {
|
const handleSettingsMobile = useCallback(() => {
|
||||||
if (!activeServerId) {
|
showMobileAgent();
|
||||||
return;
|
router.push(buildSettingsRoute());
|
||||||
}
|
}, [showMobileAgent]);
|
||||||
closeToAgent();
|
|
||||||
router.push(buildHostSettingsRoute(activeServerId));
|
|
||||||
}, [activeServerId, closeToAgent]);
|
|
||||||
|
|
||||||
const handleSettingsDesktop = useCallback(() => {
|
const handleSettingsDesktop = useCallback(() => {
|
||||||
if (!activeServerId) {
|
router.push(buildSettingsRoute());
|
||||||
return;
|
}, []);
|
||||||
}
|
|
||||||
router.push(buildHostSettingsRoute(activeServerId));
|
|
||||||
}, [activeServerId]);
|
|
||||||
|
|
||||||
const handleViewMoreNavigate = useCallback(() => {
|
const handleViewMoreNavigate = useCallback(() => {
|
||||||
if (!activeServerId) {
|
if (!activeServerId) {
|
||||||
@@ -281,7 +267,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
insetsTop={insets.top}
|
insetsTop={insets.top}
|
||||||
insetsBottom={insets.bottom}
|
insetsBottom={insets.bottom}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
closeToAgent={closeToAgent}
|
closeToAgent={showMobileAgent}
|
||||||
handleOpenProject={handleOpenProjectMobile}
|
handleOpenProject={handleOpenProjectMobile}
|
||||||
handleSettings={handleSettingsMobile}
|
handleSettings={handleSettingsMobile}
|
||||||
handleViewMoreNavigate={handleViewMoreNavigate}
|
handleViewMoreNavigate={handleViewMoreNavigate}
|
||||||
@@ -328,44 +314,6 @@ function HostSwitchOption({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionsButton({ onPress }: { onPress: () => void }) {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const isActive = pathname.includes("/sessions");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
style={({ hovered }) => [
|
|
||||||
styles.newAgentButton,
|
|
||||||
hovered && styles.newAgentButtonHovered,
|
|
||||||
isActive && styles.newAgentButtonActive,
|
|
||||||
]}
|
|
||||||
testID="sidebar-sessions"
|
|
||||||
accessible
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="Sessions"
|
|
||||||
onPress={onPress}
|
|
||||||
>
|
|
||||||
{({ hovered }) => (
|
|
||||||
<>
|
|
||||||
<MessagesSquare
|
|
||||||
size={theme.iconSize.md}
|
|
||||||
color={hovered || isActive ? theme.colors.foreground : theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.newAgentButtonText,
|
|
||||||
(hovered || isActive) && styles.newAgentButtonTextHovered,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
Sessions
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MobileSidebar({
|
function MobileSidebar({
|
||||||
theme,
|
theme,
|
||||||
activeServerId,
|
activeServerId,
|
||||||
@@ -394,6 +342,8 @@ function MobileSidebar({
|
|||||||
handleViewMoreNavigate,
|
handleViewMoreNavigate,
|
||||||
}: MobileSidebarProps) {
|
}: MobileSidebarProps) {
|
||||||
const newAgentKeys = useShortcutKeys("new-agent");
|
const newAgentKeys = useShortcutKeys("new-agent");
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isSessionsActive = pathname.includes("/sessions");
|
||||||
const {
|
const {
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
@@ -544,11 +494,13 @@ function MobileSidebar({
|
|||||||
pointerEvents="auto"
|
pointerEvents="auto"
|
||||||
>
|
>
|
||||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||||
<View style={styles.sidebarHeader}>
|
<SidebarHeaderRow
|
||||||
<View style={styles.sidebarHeaderRow}>
|
icon={MessagesSquare}
|
||||||
<SessionsButton onPress={handleViewMore} />
|
label="Sessions"
|
||||||
</View>
|
onPress={handleViewMore}
|
||||||
</View>
|
isActive={isSessionsActive}
|
||||||
|
testID="sidebar-sessions"
|
||||||
|
/>
|
||||||
|
|
||||||
{isInitialLoad ? (
|
{isInitialLoad ? (
|
||||||
<SidebarAgentListSkeleton />
|
<SidebarAgentListSkeleton />
|
||||||
@@ -676,6 +628,8 @@ function DesktopSidebar({
|
|||||||
handleViewMore,
|
handleViewMore,
|
||||||
}: DesktopSidebarProps) {
|
}: DesktopSidebarProps) {
|
||||||
const newAgentKeys = useShortcutKeys("new-agent");
|
const newAgentKeys = useShortcutKeys("new-agent");
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isSessionsActive = pathname.includes("/sessions");
|
||||||
const padding = useWindowControlsPadding("sidebar");
|
const padding = useWindowControlsPadding("sidebar");
|
||||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||||
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
|
||||||
@@ -732,11 +686,13 @@ function DesktopSidebar({
|
|||||||
<View style={styles.sidebarDragArea}>
|
<View style={styles.sidebarDragArea}>
|
||||||
<TitlebarDragRegion />
|
<TitlebarDragRegion />
|
||||||
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
|
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
|
||||||
<View style={styles.sidebarHeader}>
|
<SidebarHeaderRow
|
||||||
<View style={styles.sidebarHeaderRow}>
|
icon={MessagesSquare}
|
||||||
<SessionsButton onPress={handleViewMore} />
|
label="Sessions"
|
||||||
</View>
|
onPress={handleViewMore}
|
||||||
</View>
|
isActive={isSessionsActive}
|
||||||
|
testID="sidebar-sessions"
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isInitialLoad ? (
|
{isInitialLoad ? (
|
||||||
@@ -881,46 +837,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
sidebarDragArea: {
|
sidebarDragArea: {
|
||||||
position: "relative",
|
position: "relative",
|
||||||
},
|
},
|
||||||
sidebarHeader: {
|
|
||||||
height: {
|
|
||||||
xs: HEADER_INNER_HEIGHT_MOBILE,
|
|
||||||
md: HEADER_INNER_HEIGHT,
|
|
||||||
},
|
|
||||||
paddingHorizontal: theme.spacing[2],
|
|
||||||
justifyContent: "center",
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: theme.colors.border,
|
|
||||||
userSelect: "none",
|
|
||||||
},
|
|
||||||
sidebarHeaderRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
newAgentButton: {
|
|
||||||
flex: 1,
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
paddingVertical: theme.spacing[2],
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
},
|
|
||||||
newAgentButtonText: {
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
fontWeight: theme.fontWeight.normal,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
newAgentButtonTextHovered: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
},
|
|
||||||
newAgentButtonHovered: {
|
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
|
||||||
},
|
|
||||||
newAgentButtonActive: {
|
|
||||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
|
||||||
},
|
|
||||||
hostTrigger: {
|
hostTrigger: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import React, { createRef } from "react";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { MessageInput, type AttachmentMenuItem, type MessageInputRef } from "./message-input";
|
||||||
|
|
||||||
|
const { startDictationMock, cancelDictationMock, confirmDictationMock } = vi.hoisted(() => ({
|
||||||
|
startDictationMock: vi.fn(),
|
||||||
|
cancelDictationMock: vi.fn(),
|
||||||
|
confirmDictationMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { theme } = vi.hoisted(() => ({
|
||||||
|
theme: {
|
||||||
|
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
|
||||||
|
iconSize: { sm: 14, md: 18, lg: 22 },
|
||||||
|
borderWidth: { 1: 1 },
|
||||||
|
borderRadius: { full: 999, md: 6, lg: 8, "2xl": 16 },
|
||||||
|
fontSize: { xs: 11, sm: 13, base: 15 },
|
||||||
|
fontWeight: { normal: "400" },
|
||||||
|
shadow: { md: {} },
|
||||||
|
colors: {
|
||||||
|
surface1: "#111",
|
||||||
|
surface2: "#222",
|
||||||
|
surface3: "#333",
|
||||||
|
surface4: "#888",
|
||||||
|
foreground: "#fff",
|
||||||
|
foregroundMuted: "#aaa",
|
||||||
|
popoverForeground: "#fff",
|
||||||
|
borderAccent: "#444",
|
||||||
|
accent: "#0a84ff",
|
||||||
|
accentForeground: "#fff",
|
||||||
|
destructive: "#ff453a",
|
||||||
|
palette: {
|
||||||
|
green: { 500: "#30d158" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-unistyles", () => ({
|
||||||
|
StyleSheet: {
|
||||||
|
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||||
|
},
|
||||||
|
useUnistyles: () => ({ theme }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/constants/platform", () => ({
|
||||||
|
isWeb: false,
|
||||||
|
isNative: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("lucide-react-native", () => {
|
||||||
|
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||||
|
React.createElement("span", { ...props, "data-icon": name });
|
||||||
|
return {
|
||||||
|
ArrowUp: createIcon("ArrowUp"),
|
||||||
|
Mic: createIcon("Mic"),
|
||||||
|
MicOff: createIcon("MicOff"),
|
||||||
|
CornerDownLeft: createIcon("CornerDownLeft"),
|
||||||
|
Plus: createIcon("Plus"),
|
||||||
|
Square: createIcon("Square"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("react-native-reanimated", () => ({
|
||||||
|
default: {
|
||||||
|
View: "div",
|
||||||
|
},
|
||||||
|
Keyframe: class Keyframe {
|
||||||
|
duration() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
withCallback() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
runOnJS: (fn: (...args: unknown[]) => unknown) => fn,
|
||||||
|
useSharedValue: (value: unknown) => ({ value }),
|
||||||
|
useAnimatedStyle: (factory: () => unknown) => factory(),
|
||||||
|
withTiming: (value: unknown) => value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-dictation", () => ({
|
||||||
|
useDictation: () => ({
|
||||||
|
isRecording: false,
|
||||||
|
isProcessing: false,
|
||||||
|
partialTranscript: "",
|
||||||
|
volume: 0,
|
||||||
|
duration: 0,
|
||||||
|
error: null,
|
||||||
|
status: "idle",
|
||||||
|
startDictation: startDictationMock,
|
||||||
|
cancelDictation: cancelDictationMock,
|
||||||
|
confirmDictation: confirmDictationMock,
|
||||||
|
retryFailedDictation: vi.fn(),
|
||||||
|
discardFailedDictation: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/session-store", () => ({
|
||||||
|
useSessionStore: (selector: (state: { sessions: Record<string, unknown> }) => unknown) =>
|
||||||
|
selector({ sessions: {} }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/contexts/voice-context", () => ({
|
||||||
|
useVoiceOptional: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/contexts/toast-context", () => ({
|
||||||
|
useToast: () => ({ error: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/utils/server-info-capabilities", () => ({
|
||||||
|
resolveVoiceUnavailableMessage: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/use-web-scrollbar", () => ({
|
||||||
|
useWebElementScrollbar: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(
|
||||||
|
"@/hooks/use-web-scrollbar-style",
|
||||||
|
() => ({
|
||||||
|
useWebScrollbarStyle: () => undefined,
|
||||||
|
}),
|
||||||
|
// @ts-expect-error Vitest accepts virtual mocks at runtime; the app's types omit this overload.
|
||||||
|
{ virtual: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-shortcut-keys", () => ({
|
||||||
|
useShortcutKeys: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/shortcut", () => ({
|
||||||
|
Shortcut: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/tooltip", () => ({
|
||||||
|
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
TooltipTrigger: ({
|
||||||
|
asChild,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
asChild?: boolean;
|
||||||
|
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
|
||||||
|
} & Record<string, any>) =>
|
||||||
|
asChild ? (
|
||||||
|
<>{children}</>
|
||||||
|
) : (
|
||||||
|
<button type="button" aria-label={props.accessibilityLabel}>
|
||||||
|
{typeof children === "function" ? children({ hovered: false }) : children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||||
|
DropdownMenu: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
DropdownMenuTrigger: ({
|
||||||
|
children,
|
||||||
|
testID,
|
||||||
|
accessibilityLabel,
|
||||||
|
}: {
|
||||||
|
children:
|
||||||
|
| React.ReactNode
|
||||||
|
| ((state: { hovered: boolean; pressed: boolean; open: boolean }) => React.ReactNode);
|
||||||
|
testID?: string;
|
||||||
|
accessibilityLabel?: string;
|
||||||
|
}) => (
|
||||||
|
<button type="button" data-testid={testID} aria-label={accessibilityLabel}>
|
||||||
|
{typeof children === "function"
|
||||||
|
? children({ hovered: false, pressed: false, open: false })
|
||||||
|
: children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
|
||||||
|
<div data-testid={testID}>{children}</div>
|
||||||
|
),
|
||||||
|
DropdownMenuItem: ({
|
||||||
|
children,
|
||||||
|
onSelect,
|
||||||
|
testID,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onSelect?: () => void;
|
||||||
|
testID?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) => (
|
||||||
|
<button type="button" data-testid={testID} disabled={disabled} onClick={onSelect}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./dictation-controls", () => ({
|
||||||
|
DictationOverlay: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./realtime-voice-overlay", () => ({
|
||||||
|
RealtimeVoiceOverlay: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLElement | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||||
|
vi.stubGlobal("React", React);
|
||||||
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||||
|
vi.stubGlobal("window", dom.window);
|
||||||
|
vi.stubGlobal("document", dom.window.document);
|
||||||
|
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
|
||||||
|
vi.stubGlobal("Node", dom.window.Node);
|
||||||
|
vi.stubGlobal("navigator", dom.window.navigator);
|
||||||
|
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
startDictationMock.mockReset();
|
||||||
|
cancelDictationMock.mockReset();
|
||||||
|
confirmDictationMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root = null;
|
||||||
|
container = null;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
interface RenderMessageInputOptions {
|
||||||
|
value?: string;
|
||||||
|
submitIcon?: "arrow" | "return";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessageInput(
|
||||||
|
menuItems: AttachmentMenuItem[],
|
||||||
|
{ value = "", submitIcon }: RenderMessageInputOptions = {},
|
||||||
|
) {
|
||||||
|
act(() => {
|
||||||
|
root?.render(
|
||||||
|
<MessageInput
|
||||||
|
value={value}
|
||||||
|
onChangeText={vi.fn()}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
attachments={[]}
|
||||||
|
cwd="/repo"
|
||||||
|
attachmentMenuItems={menuItems}
|
||||||
|
client={{ isConnected: true } as never}
|
||||||
|
isAgentRunning={false}
|
||||||
|
submitIcon={submitIcon}
|
||||||
|
onQueue={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(element: Element) {
|
||||||
|
act(() => {
|
||||||
|
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryByTestId(testID: string): HTMLElement | null {
|
||||||
|
return document.querySelector(`[data-testid="${testID}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryAllByAriaLabel(label: string): NodeListOf<HTMLElement> {
|
||||||
|
return document.querySelectorAll(`[aria-label="${label}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("MessageInput attachments", () => {
|
||||||
|
it("renders the Plus attachment button and opens a menu with two attachment items", () => {
|
||||||
|
const menuItems: AttachmentMenuItem[] = [
|
||||||
|
{ id: "image", label: "Add image", onSelect: vi.fn() },
|
||||||
|
{ id: "github", label: "Add issue or PR", onSelect: vi.fn() },
|
||||||
|
];
|
||||||
|
|
||||||
|
renderMessageInput(menuItems);
|
||||||
|
|
||||||
|
expect(document.querySelectorAll('[data-icon="Plus"]')).toHaveLength(1);
|
||||||
|
|
||||||
|
const attachButton = queryByTestId("message-input-attach-button");
|
||||||
|
expect(attachButton).not.toBeNull();
|
||||||
|
click(attachButton!);
|
||||||
|
|
||||||
|
expect(queryByTestId("message-input-attachment-menu-item-image")).not.toBeNull();
|
||||||
|
expect(queryByTestId("message-input-attachment-menu-item-github")).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]'),
|
||||||
|
).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selecting Attach image invokes the supplied image attachment action", () => {
|
||||||
|
const attachImage = vi.fn();
|
||||||
|
|
||||||
|
renderMessageInput([
|
||||||
|
{ id: "image", label: "Attach image", onSelect: attachImage },
|
||||||
|
{ id: "github", label: "Attach GitHub issue or PR", onSelect: vi.fn() },
|
||||||
|
]);
|
||||||
|
|
||||||
|
click(queryByTestId("message-input-attach-button")!);
|
||||||
|
click(queryByTestId("message-input-attachment-menu-item-image")!);
|
||||||
|
|
||||||
|
expect(attachImage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render the old queue button", () => {
|
||||||
|
renderMessageInput([
|
||||||
|
{ id: "image", label: "Add image", onSelect: vi.fn() },
|
||||||
|
{ id: "github", label: "Add issue or PR", onSelect: vi.fn() },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(queryAllByAriaLabel("Queue message")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses ArrowUp by default and CornerDownLeft when return submit icon is requested", () => {
|
||||||
|
renderMessageInput([], { value: "Send this" });
|
||||||
|
|
||||||
|
expect(document.querySelectorAll('[data-icon="ArrowUp"]')).toHaveLength(1);
|
||||||
|
expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(0);
|
||||||
|
|
||||||
|
renderMessageInput([], { value: "Create this", submitIcon: "return" });
|
||||||
|
|
||||||
|
expect(document.querySelectorAll('[data-icon="ArrowUp"]')).toHaveLength(0);
|
||||||
|
expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MessageInput dictation shortcuts", () => {
|
||||||
|
it("does not poison the dictation toggle when readiness is temporarily false", () => {
|
||||||
|
const inputRef = createRef<MessageInputRef>();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root?.render(
|
||||||
|
<MessageInput
|
||||||
|
ref={inputRef}
|
||||||
|
value=""
|
||||||
|
onChangeText={vi.fn()}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
attachments={[]}
|
||||||
|
cwd="/repo"
|
||||||
|
attachmentMenuItems={[]}
|
||||||
|
client={{ isConnected: true } as never}
|
||||||
|
isAgentRunning={false}
|
||||||
|
isReadyForDictation={false}
|
||||||
|
onQueue={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
inputRef.current?.runKeyboardAction("dictation-toggle");
|
||||||
|
});
|
||||||
|
expect(startDictationMock).not.toHaveBeenCalled();
|
||||||
|
expect(confirmDictationMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root?.render(
|
||||||
|
<MessageInput
|
||||||
|
ref={inputRef}
|
||||||
|
value=""
|
||||||
|
onChangeText={vi.fn()}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
attachments={[]}
|
||||||
|
cwd="/repo"
|
||||||
|
attachmentMenuItems={[]}
|
||||||
|
client={{ isConnected: true } as never}
|
||||||
|
isAgentRunning={false}
|
||||||
|
isReadyForDictation
|
||||||
|
onQueue={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
inputRef.current?.runKeyboardAction("dictation-toggle");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(startDictationMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(confirmDictationMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
TextInputContentSizeChangeEventData,
|
TextInputContentSizeChangeEventData,
|
||||||
TextInputKeyPressEventData,
|
TextInputKeyPressEventData,
|
||||||
TextInputSelectionChangeEventData,
|
TextInputSelectionChangeEventData,
|
||||||
Image,
|
|
||||||
BackHandler,
|
BackHandler,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +20,7 @@ import {
|
|||||||
forwardRef,
|
forwardRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { Mic, MicOff, ArrowUp, Paperclip, Plus, X, Square } from "lucide-react-native";
|
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
|
||||||
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
|
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
|
||||||
import { useDictation } from "@/hooks/use-dictation";
|
import { useDictation } from "@/hooks/use-dictation";
|
||||||
import { DictationOverlay } from "./dictation-controls";
|
import { DictationOverlay } from "./dictation-controls";
|
||||||
@@ -36,10 +35,16 @@ import {
|
|||||||
filesToImageAttachments,
|
filesToImageAttachments,
|
||||||
} from "@/utils/image-attachments-from-files";
|
} from "@/utils/image-attachments-from-files";
|
||||||
import type { AttachmentMetadata } from "@/attachments/types";
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import type { ComposerAttachment } from "@/attachments/types";
|
||||||
import { focusWithRetries } from "@/utils/web-focus";
|
import { focusWithRetries } from "@/utils/web-focus";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
|
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { formatShortcut } from "@/utils/format-shortcut";
|
import { formatShortcut } from "@/utils/format-shortcut";
|
||||||
@@ -51,26 +56,44 @@ import {
|
|||||||
markScrollInvestigationRender,
|
markScrollInvestigationRender,
|
||||||
} from "@/utils/scroll-jank-investigation";
|
} from "@/utils/scroll-jank-investigation";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import { useComposerHeightMirror } from "./composer-height-mirror";
|
||||||
|
|
||||||
export type ImageAttachment = AttachmentMetadata;
|
export type ImageAttachment = AttachmentMetadata;
|
||||||
|
|
||||||
export interface MessagePayload {
|
export interface MessagePayload {
|
||||||
text: string;
|
text: string;
|
||||||
images?: ImageAttachment[];
|
attachments: ComposerAttachment[];
|
||||||
|
cwd: string;
|
||||||
/** When true, bypasses queue and sends immediately even if agent is running */
|
/** When true, bypasses queue and sends immediately even if agent is running */
|
||||||
forceSend?: boolean;
|
forceSend?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AttachmentMenuItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
onSelect: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
icon?: React.ReactElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MessageInputProps {
|
export interface MessageInputProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChangeText: (text: string) => void;
|
onChangeText: (text: string) => void;
|
||||||
onSubmit: (payload: MessagePayload) => void;
|
onSubmit: (payload: MessagePayload) => void;
|
||||||
|
/** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */
|
||||||
|
hasExternalContent?: boolean;
|
||||||
|
/** When true, the submit button stays visible and can submit even with no content. */
|
||||||
|
allowEmptySubmit?: boolean;
|
||||||
|
/** Optional accessibility label for the primary submit button. */
|
||||||
|
submitButtonAccessibilityLabel?: string;
|
||||||
|
submitIcon?: "arrow" | "return";
|
||||||
isSubmitDisabled?: boolean;
|
isSubmitDisabled?: boolean;
|
||||||
isSubmitLoading?: boolean;
|
isSubmitLoading?: boolean;
|
||||||
images?: ImageAttachment[];
|
attachments: ComposerAttachment[];
|
||||||
onPickImages?: () => void;
|
cwd: string;
|
||||||
|
attachmentMenuItems: AttachmentMenuItem[];
|
||||||
|
onAttachButtonRef?: (node: View | null) => void;
|
||||||
onAddImages?: (images: ImageAttachment[]) => void;
|
onAddImages?: (images: ImageAttachment[]) => void;
|
||||||
onRemoveImage?: (index: number) => void;
|
|
||||||
client: DaemonClient | null;
|
client: DaemonClient | null;
|
||||||
/** Dictation start gate from host runtime (socket connected + directory ready). */
|
/** Dictation start gate from host runtime (socket connected + directory ready). */
|
||||||
isReadyForDictation?: boolean;
|
isReadyForDictation?: boolean;
|
||||||
@@ -103,6 +126,8 @@ export interface MessageInputProps {
|
|||||||
onSelectionChange?: (selection: { start: number; end: number }) => void;
|
onSelectionChange?: (selection: { start: number; end: number }) => void;
|
||||||
onFocusChange?: (focused: boolean) => void;
|
onFocusChange?: (focused: boolean) => void;
|
||||||
onHeightChange?: (height: number) => void;
|
onHeightChange?: (height: number) => void;
|
||||||
|
/** Extra styles merged onto the input wrapper (e.g. elevated background). */
|
||||||
|
inputWrapperStyle?: import("react-native").ViewStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageInputRef {
|
export interface MessageInputRef {
|
||||||
@@ -116,8 +141,10 @@ export interface MessageInputRef {
|
|||||||
getNativeElement?: () => HTMLElement | null;
|
getNativeElement?: () => HTMLElement | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIN_INPUT_HEIGHT = 30;
|
const MIN_INPUT_HEIGHT_MOBILE = 30;
|
||||||
|
const MIN_INPUT_HEIGHT_DESKTOP = 46;
|
||||||
const MAX_INPUT_HEIGHT = 160;
|
const MAX_INPUT_HEIGHT = 160;
|
||||||
|
const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
|
||||||
|
|
||||||
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
||||||
TextInputKeyPressEventData & {
|
TextInputKeyPressEventData & {
|
||||||
@@ -184,25 +211,22 @@ function getScrollableAncestorChain(element: HTMLElement | null): string[] {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) {
|
|
||||||
const uri = useAttachmentPreviewUrl(image);
|
|
||||||
if (!uri) {
|
|
||||||
return <View style={styles.imageThumbnailPlaceholder} />;
|
|
||||||
}
|
|
||||||
return <Image source={{ uri }} style={styles.imageThumbnail} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(function MessageInput(
|
export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(function MessageInput(
|
||||||
{
|
{
|
||||||
value,
|
value,
|
||||||
onChangeText,
|
onChangeText,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
hasExternalContent = false,
|
||||||
|
allowEmptySubmit = false,
|
||||||
|
submitButtonAccessibilityLabel,
|
||||||
|
submitIcon = "arrow",
|
||||||
isSubmitDisabled = false,
|
isSubmitDisabled = false,
|
||||||
isSubmitLoading = false,
|
isSubmitLoading = false,
|
||||||
images = [],
|
attachments,
|
||||||
onPickImages,
|
cwd,
|
||||||
|
attachmentMenuItems,
|
||||||
|
onAttachButtonRef,
|
||||||
onAddImages,
|
onAddImages,
|
||||||
onRemoveImage,
|
|
||||||
client,
|
client,
|
||||||
isReadyForDictation,
|
isReadyForDictation,
|
||||||
placeholder = "Message...",
|
placeholder = "Message...",
|
||||||
@@ -223,6 +247,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
onSelectionChange: onSelectionChangeCallback,
|
onSelectionChange: onSelectionChangeCallback,
|
||||||
onFocusChange,
|
onFocusChange,
|
||||||
onHeightChange,
|
onHeightChange,
|
||||||
|
inputWrapperStyle,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
@@ -235,7 +260,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
const sendKeys = useShortcutKeys("message-input-send");
|
const sendKeys = useShortcutKeys("message-input-send");
|
||||||
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
|
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
|
||||||
const dictationToggleKeys = useShortcutKeys("dictation-toggle");
|
const dictationToggleKeys = useShortcutKeys("dictation-toggle");
|
||||||
const queueKeys = useShortcutKeys("message-input-queue");
|
|
||||||
const focusInputKeys = useShortcutKeys("focus-message-input");
|
const focusInputKeys = useShortcutKeys("focus-message-input");
|
||||||
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
||||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||||
@@ -307,7 +331,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
|
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
|
||||||
const baselineInputHeightRef = useRef<number | null>(null);
|
|
||||||
const overlayTransition = useSharedValue(0);
|
const overlayTransition = useSharedValue(0);
|
||||||
const sendAfterTranscriptRef = useRef(false);
|
const sendAfterTranscriptRef = useRef(false);
|
||||||
const valueRef = useRef(value);
|
const valueRef = useRef(value);
|
||||||
@@ -362,29 +385,23 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
sendAfterTranscriptRef.current = false;
|
sendAfterTranscriptRef.current = false;
|
||||||
|
|
||||||
if (shouldAutoSend) {
|
if (shouldAutoSend) {
|
||||||
const imageAttachments = images.length > 0 ? images : undefined;
|
|
||||||
// Respect send behavior setting: when "queue", dictation queues too.
|
// Respect send behavior setting: when "queue", dictation queues too.
|
||||||
if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) {
|
if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) {
|
||||||
onQueue({ text: nextValue, images: imageAttachments });
|
onQueue({ text: nextValue, attachments, cwd });
|
||||||
onChangeText("");
|
onChangeText("");
|
||||||
} else {
|
} else {
|
||||||
onSubmit({
|
onSubmit({
|
||||||
text: nextValue,
|
text: nextValue,
|
||||||
images: imageAttachments,
|
attachments,
|
||||||
|
cwd,
|
||||||
forceSend: isAgentRunning || undefined,
|
forceSend: isAgentRunning || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
onChangeText(nextValue);
|
onChangeText(nextValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isWeb && typeof requestAnimationFrame === "function") {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
measureWebInputHeight("dictation");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[onChangeText, onSubmit, onQueue, images, isAgentRunning, defaultSendBehavior],
|
[onChangeText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDictationError = useCallback(
|
const handleDictationError = useCallback(
|
||||||
@@ -463,11 +480,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
toast.error(dictationUnavailableMessage);
|
toast.error(dictationUnavailableMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canStartDictation()) {
|
||||||
|
isDictatingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Keep hotkey toggling deterministic between the async start call and the
|
// Keep hotkey toggling deterministic between the async start call and the
|
||||||
// state-ref sync effect, so a rapid second toggle routes to confirm.
|
// state-ref sync effect, so a rapid second toggle routes to confirm.
|
||||||
isDictatingRef.current = true;
|
isDictatingRef.current = true;
|
||||||
await startDictation();
|
await startDictation();
|
||||||
}, [dictationUnavailableMessage, startDictation, toast]);
|
}, [canStartDictation, dictationUnavailableMessage, startDictation, toast]);
|
||||||
|
|
||||||
// Animate overlay
|
// Animate overlay
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -568,32 +589,43 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
|
|
||||||
const handleSendMessage = useCallback(() => {
|
const handleSendMessage = useCallback(() => {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed && images.length === 0) return;
|
if (!trimmed && attachments.length === 0 && !hasExternalContent && !allowEmptySubmit) return;
|
||||||
const payload = {
|
const payload = {
|
||||||
text: trimmed,
|
text: trimmed,
|
||||||
images: images.length > 0 ? images : undefined,
|
attachments,
|
||||||
|
cwd,
|
||||||
forceSend: isAgentRunning || undefined,
|
forceSend: isAgentRunning || undefined,
|
||||||
};
|
};
|
||||||
onSubmit(payload);
|
onSubmit(payload);
|
||||||
inputHeightRef.current = MIN_INPUT_HEIGHT;
|
inputHeightRef.current = MIN_INPUT_HEIGHT;
|
||||||
setInputHeight(MIN_INPUT_HEIGHT);
|
setInputHeight(MIN_INPUT_HEIGHT);
|
||||||
onHeightChange?.(MIN_INPUT_HEIGHT);
|
onHeightChange?.(MIN_INPUT_HEIGHT);
|
||||||
}, [value, images, onSubmit, isAgentRunning, onHeightChange]);
|
}, [
|
||||||
|
allowEmptySubmit,
|
||||||
|
value,
|
||||||
|
attachments,
|
||||||
|
cwd,
|
||||||
|
onSubmit,
|
||||||
|
isAgentRunning,
|
||||||
|
onHeightChange,
|
||||||
|
hasExternalContent,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleQueueMessage = useCallback(() => {
|
const handleQueueMessage = useCallback(() => {
|
||||||
if (!onQueue) return;
|
if (!onQueue) return;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed && images.length === 0) return;
|
if (!trimmed && attachments.length === 0) return;
|
||||||
const payload = {
|
const payload = {
|
||||||
text: trimmed,
|
text: trimmed,
|
||||||
images: images.length > 0 ? images : undefined,
|
attachments,
|
||||||
|
cwd,
|
||||||
};
|
};
|
||||||
onQueue(payload);
|
onQueue(payload);
|
||||||
onChangeText("");
|
onChangeText("");
|
||||||
inputHeightRef.current = MIN_INPUT_HEIGHT;
|
inputHeightRef.current = MIN_INPUT_HEIGHT;
|
||||||
setInputHeight(MIN_INPUT_HEIGHT);
|
setInputHeight(MIN_INPUT_HEIGHT);
|
||||||
onHeightChange?.(MIN_INPUT_HEIGHT);
|
onHeightChange?.(MIN_INPUT_HEIGHT);
|
||||||
}, [value, images, onQueue, onChangeText, onHeightChange]);
|
}, [value, attachments, cwd, onQueue, onChangeText, onHeightChange]);
|
||||||
|
|
||||||
// Default send action: respects the sendBehavior setting.
|
// Default send action: respects the sendBehavior setting.
|
||||||
// When "interrupt" (default), primary action sends immediately (interrupts).
|
// When "interrupt" (default), primary action sends immediately (interrupts).
|
||||||
@@ -798,76 +830,27 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
};
|
};
|
||||||
}, [getWebTextArea]);
|
}, [getWebTextArea]);
|
||||||
|
|
||||||
function measureWebInputHeight(source: string): boolean {
|
|
||||||
if (!isWeb) return false;
|
|
||||||
const textarea = getWebTextArea();
|
|
||||||
if (!textarea || typeof textarea.scrollHeight !== "number") return false;
|
|
||||||
const scrollHeight = textarea.scrollHeight ?? 0;
|
|
||||||
|
|
||||||
if (baselineInputHeightRef.current === null && scrollHeight > 0) {
|
|
||||||
baselineInputHeightRef.current = scrollHeight;
|
|
||||||
logWebStickyBottom("composer_baseline_measured", {
|
|
||||||
source,
|
|
||||||
baseline: scrollHeight,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseline = baselineInputHeightRef.current ?? MIN_INPUT_HEIGHT;
|
|
||||||
const rawTarget = scrollHeight > 0 ? scrollHeight : baseline;
|
|
||||||
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, rawTarget));
|
|
||||||
|
|
||||||
const previousHeight = inputHeightRef.current;
|
|
||||||
if (Math.abs(previousHeight - bounded) >= 1) {
|
|
||||||
inputHeightRef.current = bounded;
|
|
||||||
setInputHeight(bounded);
|
|
||||||
onHeightChange?.(bounded);
|
|
||||||
logWebStickyBottom("composer_height_changed", {
|
|
||||||
source,
|
|
||||||
previousHeight,
|
|
||||||
nextHeight: bounded,
|
|
||||||
scrollHeight,
|
|
||||||
clientHeight: textarea.clientHeight ?? null,
|
|
||||||
offsetHeight: textarea.offsetHeight ?? null,
|
|
||||||
baseline,
|
|
||||||
rawTarget,
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setBoundedInputHeight(nextHeight: number) {
|
function setBoundedInputHeight(nextHeight: number) {
|
||||||
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight));
|
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight));
|
||||||
if (Math.abs(inputHeightRef.current - bounded) < 1) return;
|
if (Math.abs(inputHeightRef.current - bounded) < 1) return;
|
||||||
const previousHeight = inputHeightRef.current;
|
|
||||||
inputHeightRef.current = bounded;
|
inputHeightRef.current = bounded;
|
||||||
setInputHeight(bounded);
|
setInputHeight(bounded);
|
||||||
onHeightChange?.(bounded);
|
onHeightChange?.(bounded);
|
||||||
logWebStickyBottom("composer_height_changed_native", {
|
|
||||||
previousHeight,
|
|
||||||
nextHeight: bounded,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useComposerHeightMirror({
|
||||||
|
value,
|
||||||
|
textareaRef: webTextareaRef,
|
||||||
|
minHeight: MIN_INPUT_HEIGHT,
|
||||||
|
maxHeight: MAX_INPUT_HEIGHT,
|
||||||
|
onHeight: setBoundedInputHeight,
|
||||||
|
});
|
||||||
|
|
||||||
function handleContentSizeChange(
|
function handleContentSizeChange(
|
||||||
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
|
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
|
||||||
) {
|
) {
|
||||||
const contentHeight = event.nativeEvent.contentSize.height;
|
if (isWeb) return;
|
||||||
if (isWeb) {
|
setBoundedInputHeight(event.nativeEvent.contentSize.height);
|
||||||
logWebStickyBottom("composer_content_size_change", {
|
|
||||||
reportedHeight: contentHeight,
|
|
||||||
});
|
|
||||||
if (baselineInputHeightRef.current === null && contentHeight > 0) {
|
|
||||||
baselineInputHeightRef.current = contentHeight;
|
|
||||||
logWebStickyBottom("composer_baseline_measured", {
|
|
||||||
source: "contentSizeChange",
|
|
||||||
baseline: contentHeight,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setBoundedInputHeight(contentHeight);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setBoundedInputHeight(contentHeight);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
|
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
|
||||||
@@ -928,20 +911,23 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
handleDefaultSendAction();
|
handleDefaultSendAction();
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasImages = images.length > 0;
|
const hasAttachments = attachments.length > 0;
|
||||||
const hasSendableContent = value.trim().length > 0 || hasImages;
|
const hasRealContent = value.trim().length > 0 || hasAttachments;
|
||||||
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
|
const hasSendableContent = hasRealContent || hasExternalContent;
|
||||||
|
const shouldShowSendButton = hasSendableContent || allowEmptySubmit || isSubmitLoading;
|
||||||
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
|
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
|
||||||
const isSendButtonDisabled =
|
const isSendButtonDisabled =
|
||||||
disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
|
disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
|
||||||
const defaultActionQueues = defaultSendBehavior === "queue" && isAgentRunning;
|
const defaultActionQueues = defaultSendBehavior === "queue" && isAgentRunning;
|
||||||
const submitAccessibilityLabel = canPressLoadingButton
|
const defaultSubmitAccessibilityLabel = canPressLoadingButton
|
||||||
? "Interrupt agent"
|
? "Interrupt agent"
|
||||||
: defaultActionQueues
|
: defaultActionQueues
|
||||||
? "Queue message"
|
? "Queue message"
|
||||||
: isAgentRunning
|
: isAgentRunning
|
||||||
? "Send and interrupt"
|
? "Send and interrupt"
|
||||||
: "Send message";
|
: "Send message";
|
||||||
|
const submitAccessibilityLabel =
|
||||||
|
submitButtonAccessibilityLabel ?? defaultSubmitAccessibilityLabel;
|
||||||
|
|
||||||
const handleInputChange = useCallback(
|
const handleInputChange = useCallback(
|
||||||
(nextValue: string) => {
|
(nextValue: string) => {
|
||||||
@@ -960,37 +946,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
return (
|
return (
|
||||||
<View ref={rootRef} style={styles.container} testID="message-input-root">
|
<View ref={rootRef} style={styles.container} testID="message-input-root">
|
||||||
{/* Regular input */}
|
{/* Regular input */}
|
||||||
<Animated.View ref={inputWrapperRef} style={[styles.inputWrapper, inputAnimatedStyle]}>
|
<Animated.View
|
||||||
{/* Image preview pills */}
|
ref={inputWrapperRef}
|
||||||
{hasImages && (
|
style={[styles.inputWrapper, inputWrapperStyle, inputAnimatedStyle]}
|
||||||
<View style={styles.imagePreviewContainer} testID="message-input-image-preview">
|
>
|
||||||
{images.map((image, index) => (
|
|
||||||
<Pressable
|
|
||||||
key={`${image.id}-${index}`}
|
|
||||||
testID="message-input-image-pill"
|
|
||||||
style={styles.imagePill}
|
|
||||||
onPress={onRemoveImage ? () => onRemoveImage(index) : undefined}
|
|
||||||
>
|
|
||||||
{({ hovered }) => (
|
|
||||||
<>
|
|
||||||
<ImageAttachmentThumbnail image={image} />
|
|
||||||
{onRemoveImage && (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.removeImageButton,
|
|
||||||
(hovered || !isWeb) && styles.removeImageButtonVisible,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<X size={theme.iconSize.md} color="white" />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Text input */}
|
{/* Text input */}
|
||||||
<View style={styles.textInputScrollWrapper}>
|
<View style={styles.textInputScrollWrapper}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -1043,28 +1002,58 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
<View style={styles.buttonRow}>
|
<View style={styles.buttonRow}>
|
||||||
{/* Left: attachment button + leftContent slot */}
|
{/* Left: attachment button + leftContent slot */}
|
||||||
<View style={styles.leftButtonGroup}>
|
<View style={styles.leftButtonGroup}>
|
||||||
{onPickImages && (
|
<DropdownMenu>
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Pressable
|
<DropdownMenuTrigger
|
||||||
onPress={onPickImages}
|
|
||||||
disabled={!isConnected || disabled}
|
disabled={!isConnected || disabled}
|
||||||
accessibilityLabel="Attach images"
|
accessibilityLabel="Add attachment"
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
|
testID="message-input-attach-button"
|
||||||
style={({ hovered }) => [
|
style={({ hovered }) => [
|
||||||
styles.attachButton,
|
styles.attachButton,
|
||||||
hovered && styles.iconButtonHovered,
|
hovered && styles.iconButtonHovered,
|
||||||
(!isConnected || disabled) && styles.buttonDisabled,
|
(!isConnected || disabled) && styles.buttonDisabled,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
|
{({ hovered }) => (
|
||||||
</Pressable>
|
<View
|
||||||
|
ref={onAttachButtonRef}
|
||||||
|
collapsable={false}
|
||||||
|
style={styles.attachButtonAnchor}
|
||||||
|
>
|
||||||
|
<Plus
|
||||||
|
size={buttonIconSize}
|
||||||
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<Text style={styles.tooltipText}>Attach images</Text>
|
<Text style={styles.tooltipText}>Add attachment</Text>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
<DropdownMenuContent
|
||||||
|
side="top"
|
||||||
|
align="start"
|
||||||
|
offset={8}
|
||||||
|
minWidth={220}
|
||||||
|
testID="message-input-attachment-menu"
|
||||||
|
>
|
||||||
|
{attachmentMenuItems.map((item) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={item.id}
|
||||||
|
testID={`message-input-attachment-menu-item-${item.id}`}
|
||||||
|
disabled={item.disabled}
|
||||||
|
onSelect={item.onSelect}
|
||||||
|
leading={item.icon ?? null}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
{leftContent}
|
{leftContent}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -1092,13 +1081,21 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
isDictating && styles.voiceButtonRecording,
|
isDictating && styles.voiceButtonRecording,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{isDictating ? (
|
{({ hovered }) =>
|
||||||
<Square size={buttonIconSize} color="white" fill="white" />
|
isDictating ? (
|
||||||
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
|
<Square size={buttonIconSize} color="white" fill="white" />
|
||||||
<MicOff size={buttonIconSize} color={theme.colors.foreground} />
|
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
|
||||||
) : (
|
<MicOff
|
||||||
<Mic size={buttonIconSize} color={theme.colors.foreground} />
|
size={buttonIconSize}
|
||||||
)}
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Mic
|
||||||
|
size={buttonIconSize}
|
||||||
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<View style={styles.tooltipRow}>
|
<View style={styles.tooltipRow}>
|
||||||
@@ -1123,31 +1120,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{rightContent}
|
{rightContent}
|
||||||
{hasSendableContent && isAgentRunning && onQueue && !defaultActionQueues && (
|
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
|
||||||
<TooltipTrigger
|
|
||||||
onPress={handleAlternateSendAction}
|
|
||||||
disabled={!isConnected || disabled}
|
|
||||||
accessibilityLabel="Queue message"
|
|
||||||
accessibilityRole="button"
|
|
||||||
style={({ hovered }) => [
|
|
||||||
styles.queueButton,
|
|
||||||
hovered && styles.iconButtonHovered,
|
|
||||||
(!isConnected || disabled) && styles.buttonDisabled,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Plus size={buttonIconSize} color="white" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
|
||||||
<View style={styles.tooltipRow}>
|
|
||||||
<Text style={styles.tooltipText}>Queue</Text>
|
|
||||||
{queueKeys ? (
|
|
||||||
<Shortcut chord={queueKeys} style={styles.tooltipShortcut} />
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{shouldShowSendButton && (
|
{shouldShowSendButton && (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
@@ -1159,13 +1131,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
>
|
>
|
||||||
{isSubmitLoading ? (
|
{isSubmitLoading ? (
|
||||||
<ActivityIndicator size="small" color="white" />
|
<ActivityIndicator size="small" color="white" />
|
||||||
|
) : submitIcon === "return" ? (
|
||||||
|
<CornerDownLeft size={buttonIconSize} color="white" />
|
||||||
) : (
|
) : (
|
||||||
<ArrowUp size={buttonIconSize} color="white" />
|
<ArrowUp size={buttonIconSize} color="white" />
|
||||||
)}
|
)}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<View style={styles.tooltipRow}>
|
<View style={styles.tooltipRow}>
|
||||||
<Text style={styles.tooltipText}>{defaultActionQueues ? "Queue" : "Send"}</Text>
|
<Text style={styles.tooltipText}>
|
||||||
|
{submitButtonAccessibilityLabel ?? (defaultActionQueues ? "Queue" : "Send")}
|
||||||
|
</Text>
|
||||||
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
|
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
|
||||||
</View>
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
@@ -1233,52 +1209,6 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
imagePreviewContainer: {
|
|
||||||
flexDirection: "row",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
flexWrap: "wrap",
|
|
||||||
},
|
|
||||||
imagePill: {
|
|
||||||
position: "relative",
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.colors.borderAccent,
|
|
||||||
overflow: "hidden",
|
|
||||||
...(isWeb
|
|
||||||
? {
|
|
||||||
cursor: "pointer",
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
imageThumbnail: {
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
},
|
|
||||||
imageThumbnailPlaceholder: {
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
},
|
|
||||||
removeImageButton: {
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
||||||
opacity: 0,
|
|
||||||
...(isWeb
|
|
||||||
? {
|
|
||||||
transitionProperty: "opacity",
|
|
||||||
transitionDuration: "150ms",
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
removeImageButtonVisible: {
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
textInputScrollWrapper: {
|
textInputScrollWrapper: {
|
||||||
position: "relative",
|
position: "relative",
|
||||||
},
|
},
|
||||||
@@ -1308,6 +1238,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
|
marginHorizontal: -6,
|
||||||
},
|
},
|
||||||
leftButtonGroup: {
|
leftButtonGroup: {
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
@@ -1315,13 +1246,13 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "flex-end",
|
alignItems: "flex-end",
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[0],
|
||||||
},
|
},
|
||||||
rightButtonGroup: {
|
rightButtonGroup: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: isWeb ? theme.spacing[2] : theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
attachButton: {
|
attachButton: {
|
||||||
width: 28,
|
width: 28,
|
||||||
@@ -1330,6 +1261,12 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
|
attachButtonAnchor: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
voiceButton: {
|
voiceButton: {
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
@@ -1340,14 +1277,6 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
voiceButtonRecording: {
|
voiceButtonRecording: {
|
||||||
backgroundColor: theme.colors.destructive,
|
backgroundColor: theme.colors.destructive,
|
||||||
},
|
},
|
||||||
queueButton: {
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
backgroundColor: theme.colors.surface1,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
sendButton: {
|
sendButton: {
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
@@ -1355,6 +1284,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
backgroundColor: theme.colors.accent,
|
backgroundColor: theme.colors.accent,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
marginLeft: theme.spacing[1],
|
||||||
},
|
},
|
||||||
iconButtonHovered: {
|
iconButtonHovered: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user