mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 03:03:04 +00:00
Compare commits
240
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d6a154f6e | ||
|
|
5c3cc99d8c | ||
|
|
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 | ||
|
|
727aa0cdac | ||
|
|
a387eaf26a | ||
|
|
a11c246bdc | ||
|
|
2e95003154 | ||
|
|
1b4483615a | ||
|
|
4d0fe57a7a | ||
|
|
84db0cecd7 | ||
|
|
14ac6984f3 | ||
|
|
b6147e39ab | ||
|
|
a2b743a6d3 | ||
|
|
7442323ca2 | ||
|
|
086b3098b1 | ||
|
|
e0f82e7ad0 | ||
|
|
9cee1d8196 | ||
|
|
c2a8e56d33 | ||
|
|
de33dfd034 | ||
|
|
917c2f56f4 | ||
|
|
a3822ca299 | ||
|
|
41fd45588f | ||
|
|
54b8126deb | ||
|
|
e70fa2dc84 | ||
|
|
cbb690efd0 | ||
|
|
7c50b7a080 | ||
|
|
21742bbeb4 | ||
|
|
11b407c804 | ||
|
|
814098852c | ||
|
|
5c6f175db5 | ||
|
|
278acf5fe1 | ||
|
|
165ae58f75 | ||
|
|
a1f3256923 | ||
|
|
86bb5cc827 | ||
|
|
483312e1dc | ||
|
|
0b3c29b2ec | ||
|
|
1885d8602f | ||
|
|
57312d4f75 | ||
|
|
ffbb2ffd06 | ||
|
|
45a5ba8637 | ||
|
|
b8ade39f73 | ||
|
|
deca82653e | ||
|
|
4e9d80d118 | ||
|
|
5a20e78dff | ||
|
|
e6044264e2 | ||
|
|
beeb315c8d | ||
|
|
e635c6beca | ||
|
|
918949c7fa | ||
|
|
c1df523a77 | ||
|
|
60cc73f87e | ||
|
|
7a8f65805d | ||
|
|
43bec954ea | ||
|
|
751a1df682 | ||
|
|
79ddf6980a | ||
|
|
9d30b7e85a | ||
|
|
89b87c0235 | ||
|
|
fc96b260d1 | ||
|
|
3fe79535cc | ||
|
|
d40e4b6f0d | ||
|
|
8f04e65cf1 | ||
|
|
c5295a4ab3 | ||
|
|
f59af4c810 | ||
|
|
e012c8f52e | ||
|
|
312a6e22fe | ||
|
|
7f92f7f51d | ||
|
|
200453f032 | ||
|
|
d4a4015804 | ||
|
|
32b68cbcf4 | ||
|
|
c8cafbabff | ||
|
|
f0ac632732 | ||
|
|
25fd93d7e2 | ||
|
|
2090d685ed | ||
|
|
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
|
||||||
|
|
||||||
@@ -99,7 +102,22 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Windows-critical server tests
|
- name: Run Windows-critical server tests
|
||||||
working-directory: packages/server
|
working-directory: packages/server
|
||||||
run: npx vitest run src/utils/executable.test.ts src/utils/spawn.test.ts src/utils/run-git-command.test.ts src/server/agent/provider-registry.test.ts src/server/agent/provider-launch-config.test.ts src/server/agent/provider-snapshot-manager.test.ts src/server/persisted-config.test.ts
|
run: >
|
||||||
|
npx vitest run
|
||||||
|
src/utils/executable.test.ts
|
||||||
|
src/utils/spawn.launch-regression.test.ts
|
||||||
|
src/utils/spawn.test.ts
|
||||||
|
src/utils/run-git-command.test.ts
|
||||||
|
src/utils/checkout-git-rev-parse.test.ts
|
||||||
|
src/server/agent/provider-registry.test.ts
|
||||||
|
src/server/agent/provider-launch-config.test.ts
|
||||||
|
src/server/agent/provider-snapshot-manager.test.ts
|
||||||
|
src/server/agent/providers/claude-agent.spawn.test.ts
|
||||||
|
src/server/agent/providers/provider-windows-launch.test.ts
|
||||||
|
src/server/agent/providers/provider-availability.test.ts
|
||||||
|
src/server/workspace-registry-model.test.ts
|
||||||
|
src/server/persisted-config.test.ts
|
||||||
|
src/server/bootstrap-provider-availability.test.ts
|
||||||
|
|
||||||
app-tests:
|
app-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -1,5 +1,45 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Markdown files render as formatted markdown in the file pane. ([#427](https://github.com/getpaseo/paseo/pull/427) by [@aaronflorey](https://github.com/aaronflorey))
|
||||||
|
- Cmd+L (Ctrl+L on Windows/Linux) focuses the agent message input.
|
||||||
|
- Provider models refresh on a freshness TTL; Settings shows last-updated time and any fetch errors. ([#426](https://github.com/getpaseo/paseo/pull/426))
|
||||||
|
- `disallowedTools` option in provider config to block specific tools from an agent.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Windows: agents launch reliably from npm `.cmd` shims, paths with spaces, and JSON config args — fixes `spawn EINVAL` startup errors. ([#454](https://github.com/getpaseo/paseo/pull/454))
|
||||||
|
- OpenCode permission prompts include the requesting tool's context. ([#398](https://github.com/getpaseo/paseo/pull/398) by [@aaronflorey](https://github.com/aaronflorey))
|
||||||
|
- OpenCode todo and compaction events render in the timeline. ([#429](https://github.com/getpaseo/paseo/pull/429) by [@aaronflorey](https://github.com/aaronflorey))
|
||||||
|
- OpenCode sessions archive cleanly when closed. ([#408](https://github.com/getpaseo/paseo/pull/408) by [@aaronflorey](https://github.com/aaronflorey))
|
||||||
|
- OpenCode slash commands recover from SSE timeouts. ([#407](https://github.com/getpaseo/paseo/pull/407) by [@aaronflorey](https://github.com/aaronflorey))
|
||||||
|
- Paseo MCP tools work against archived agents, matching the CLI. ([#423](https://github.com/getpaseo/paseo/pull/423))
|
||||||
|
- Native scrollbars match the active theme across all web views. ([#399](https://github.com/getpaseo/paseo/pull/399) by [@ethersh](https://github.com/ethersh))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Code file previews can be selected and copied on iOS. ([#447](https://github.com/getpaseo/paseo/pull/447) by [@muzhi1991](https://github.com/muzhi1991))
|
||||||
|
- File preview no longer shows stale content when reopening the same file. ([#411](https://github.com/getpaseo/paseo/pull/411) by [@muzhi1991](https://github.com/muzhi1991))
|
||||||
|
- File explorer reinitialises when the client reconnects after a page refresh. ([#442](https://github.com/getpaseo/paseo/pull/442) by [@1996fanrui](https://github.com/1996fanrui))
|
||||||
|
- Generic ACP providers no longer receive duplicated command arguments. ([#444](https://github.com/getpaseo/paseo/pull/444) by [@edvardchen](https://github.com/edvardchen))
|
||||||
|
- Workspace headers no longer show a branch icon for non-git workspaces.
|
||||||
|
- Branch switcher layout is stable on mobile.
|
||||||
|
- Model names no longer truncate mid-word in the picker rows.
|
||||||
|
- Messages appear in the correct order after reconnecting on mobile.
|
||||||
|
- Clearing agent attention no longer throws on timeout.
|
||||||
|
|
||||||
|
## 0.1.56 - 2026-04-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Projects with empty git repositories (no commits yet) no longer crash the app on startup.
|
||||||
|
- A single problematic project can no longer prevent the rest of your workspaces from loading.
|
||||||
|
|
||||||
## 0.1.55 - 2026-04-14
|
## 0.1.55 - 2026-04-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
|
|||||||
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
|
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
|
||||||
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
||||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||||
|
- **NEVER run the full test suite locally.** The test suites are heavy and will freeze the machine, especially if multiple agents run them in parallel. Rules:
|
||||||
|
- Run only the specific test file you changed: `npx vitest run <file> --bail=1`
|
||||||
|
- Never run `npm run test` for an entire workspace unless explicitly asked.
|
||||||
|
- If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run <file> --bail=1 > /tmp/test-output.txt 2>&1` then read the file.
|
||||||
|
- Never re-run a test suite that another agent already ran and reported green — trust the result.
|
||||||
|
- For full suite verification, push to CI and check GitHub Actions instead.
|
||||||
- **Always run typecheck after every change.**
|
- **Always run typecheck after every change.**
|
||||||
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
|
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
|
||||||
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
|
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ Host header validation and CORS origin checks are defense-in-depth controls for
|
|||||||
|
|
||||||
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
|
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
|
||||||
|
|
||||||
Paseo uses a host allowlist to validate the `Host` header on incoming requests. Requests with unrecognized hosts are rejected.
|
Paseo validates the `Host` header on incoming requests against configured hostnames. Requests with unrecognized hosts are rejected.
|
||||||
|
|
||||||
## Agent authentication
|
## Agent authentication
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const daemon = await createPaseoDaemon(
|
|||||||
listen: "127.0.0.1:0", // OS picks a free port
|
listen: "127.0.0.1:0", // OS picks a free port
|
||||||
paseoHome,
|
paseoHome,
|
||||||
corsAllowedOrigins: [],
|
corsAllowedOrigins: [],
|
||||||
allowedHosts: true,
|
hostnames: true,
|
||||||
mcpEnabled: false,
|
mcpEnabled: false,
|
||||||
staticDir,
|
staticDir,
|
||||||
mcpDebug: false,
|
mcpDebug: false,
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -107,6 +107,7 @@ Required fields for custom providers:
|
|||||||
- `ANTHROPIC_AUTH_TOKEN` is used instead of `ANTHROPIC_API_KEY` — this is the z.ai API key
|
- `ANTHROPIC_AUTH_TOKEN` is used instead of `ANTHROPIC_API_KEY` — this is the z.ai API key
|
||||||
- The `API_TIMEOUT_MS` env var extends the request timeout (z.ai can be slower than direct Anthropic)
|
- The `API_TIMEOUT_MS` env var extends the request timeout (z.ai can be slower than direct Anthropic)
|
||||||
- If you get auth errors, run `/logout` inside Claude Code before switching to the z.ai provider
|
- If you get auth errors, run `/logout` inside Claude Code before switching to the z.ai provider
|
||||||
|
- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors.
|
||||||
- Automated setup is also available: `npx @z_ai/coding-helper`
|
- Automated setup is also available: `npx @z_ai/coding-helper`
|
||||||
- Official docs: [docs.z.ai/devpack/tool/claude](https://docs.z.ai/devpack/tool/claude)
|
- Official docs: [docs.z.ai/devpack/tool/claude](https://docs.z.ai/devpack/tool/claude)
|
||||||
|
|
||||||
@@ -173,6 +174,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
|
|||||||
|
|
||||||
- API keys must be created in the **Singapore region**
|
- API keys must be created in the **Singapore region**
|
||||||
- The coding plan is for personal use only in interactive coding tools
|
- The coding plan is for personal use only in interactive coding tools
|
||||||
|
- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors.
|
||||||
- Official docs: [alibabacloud.com/help/en/model-studio/claude-code-coding-plan](https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan)
|
- Official docs: [alibabacloud.com/help/en/model-studio/claude-code-coding-plan](https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -436,6 +438,7 @@ Every entry under `agents.providers` accepts these fields:
|
|||||||
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
|
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
|
||||||
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
|
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
|
||||||
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
|
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
|
||||||
|
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
|
||||||
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
|
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
|
||||||
| `order` | `number` | No | Sort order in the provider list |
|
| `order` | `number` | No | Sort order in the provider list |
|
||||||
|
|
||||||
@@ -460,6 +463,29 @@ Each entry in the `models` array:
|
|||||||
| `description` | `string` | No | Short description |
|
| `description` | `string` | No | Short description |
|
||||||
| `isDefault` | `boolean` | No | Mark as the default thinking option |
|
| `isDefault` | `boolean` | No | Mark as the default thinking option |
|
||||||
|
|
||||||
|
### Gotcha: `extends: "claude"` with third-party endpoints
|
||||||
|
|
||||||
|
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
|
||||||
|
|
||||||
|
Use `disallowedTools` to disable unsupported tools:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"providers": {
|
||||||
|
"my-proxy": {
|
||||||
|
"extends": "claude",
|
||||||
|
"label": "My Proxy",
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1"
|
||||||
|
},
|
||||||
|
"disallowedTools": ["WebSearch"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Valid `extends` values
|
### Valid `extends` values
|
||||||
|
|
||||||
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`
|
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`
|
||||||
|
|||||||
+1
-1
@@ -130,7 +130,7 @@ Single file, validated with `PersistedConfigSchema`.
|
|||||||
version: 1,
|
version: 1,
|
||||||
daemon: {
|
daemon: {
|
||||||
listen: "127.0.0.1:6767",
|
listen: "127.0.0.1:6767",
|
||||||
allowedHosts: true | string[],
|
hostnames: true | string[],
|
||||||
mcp: { enabled: boolean },
|
mcp: { enabled: boolean },
|
||||||
cors: { allowedOrigins: string[] },
|
cors: { allowedOrigins: string[] },
|
||||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
|
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
|
||||||
|
|||||||
@@ -35,6 +35,25 @@ 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.
|
||||||
|
|
||||||
## 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.
|
||||||
|
|||||||
@@ -139,6 +139,43 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
|
|||||||
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
|
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
|
||||||
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
||||||
|
|
||||||
|
## Changelog conciseness
|
||||||
|
|
||||||
|
Every bullet must be scannable at a glance. The changelog is not release documentation — it's a list.
|
||||||
|
|
||||||
|
- **One line per bullet.** If a bullet wraps to three lines in a narrow column, it's too long.
|
||||||
|
- **Split bullets that pack multiple distinct changes.** If a bullet uses "and", "plus", a comma list, or an em-dash to chain several independent improvements, break them into separate bullets — even when they share a theme or author. One bullet = one user-facing change.
|
||||||
|
- **Trim qualifying clauses.** Drop "with a hint shown when…", "matching the CLI's behaviour", "across common install shapes". If the detail doesn't change whether a user cares, cut it.
|
||||||
|
- **Lead with the outcome.** "Windows: agents launch reliably from npm `.cmd` shims…" is better than "Windows: agents launch reliably across common install shapes. Claude, Codex, and OpenCode now start correctly…".
|
||||||
|
- **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets.
|
||||||
|
|
||||||
|
## Changelog attribution
|
||||||
|
|
||||||
|
Every changelog bullet must credit contributors and link to the PR(s) that delivered the change. This is not one-PR-per-line — a single bullet describes a user-facing change and may reference multiple PRs.
|
||||||
|
|
||||||
|
Format: append `([#123](https://github.com/getpaseo/paseo/pull/123) by [@user](https://github.com/user))` at the end of each bullet. For changes spanning multiple PRs or contributors:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- Voice mode now works on tablets with proper microphone permissions. ([#210](https://github.com/getpaseo/paseo/pull/210), [#215](https://github.com/getpaseo/paseo/pull/215) by [@alice](https://github.com/alice), [@bob](https://github.com/bob))
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- **Always link the PR number** as `[#N](https://github.com/getpaseo/paseo/pull/N)`.
|
||||||
|
- **Always link the contributor's GitHub profile** as `[@user](https://github.com/user)`.
|
||||||
|
- **One bullet = one user-facing change**, regardless of how many PRs went into it. Group related PRs on the same bullet.
|
||||||
|
- **De-duplicate contributors.** If the same person authored multiple PRs in one bullet, list them once.
|
||||||
|
- **Only credit external contributors.** Skip attribution for [@boudra](https://github.com/boudra). The changelog credits community contributions — core team work is the default.
|
||||||
|
- **Use `git log` to find PR numbers and authors.** PR numbers are typically in the commit message as `(#N)`. Use `gh pr view N --json author` if the commit doesn't include the GitHub username.
|
||||||
|
|
||||||
|
## Changelog ordering
|
||||||
|
|
||||||
|
Entries within each section (Added, Improved, Fixed) are ordered by user impact:
|
||||||
|
|
||||||
|
1. **User-facing features and changes first** — things users will notice, want to try, or that change their workflow.
|
||||||
|
2. **Quality-of-life improvements** — polish, performance, smoother interactions.
|
||||||
|
3. **Internal/infra changes last** — only include if they have a tangible user benefit (e.g. "faster startup" is user-facing even if the fix was internal).
|
||||||
|
|
||||||
## 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 (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# 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`, and 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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>)
|
||||||
+10
-6
@@ -9,6 +9,10 @@ let
|
|||||||
cfg = config.services.paseo;
|
cfg = config.services.paseo;
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
imports = [
|
||||||
|
(lib.mkRenamedOptionModule [ "services" "paseo" "allowedHosts" ] [ "services" "paseo" "hostnames" ])
|
||||||
|
];
|
||||||
|
|
||||||
options.services.paseo = {
|
options.services.paseo = {
|
||||||
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
|
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
|
||||||
|
|
||||||
@@ -58,12 +62,12 @@ in
|
|||||||
description = "Whether to open the firewall for the Paseo daemon port.";
|
description = "Whether to open the firewall for the Paseo daemon port.";
|
||||||
};
|
};
|
||||||
|
|
||||||
allowedHosts = lib.mkOption {
|
hostnames = lib.mkOption {
|
||||||
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
|
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
|
||||||
default = [ ];
|
default = [ ];
|
||||||
example = [ ".example.com" "myhost.local" ];
|
example = [ ".example.com" "myhost.local" ];
|
||||||
description = ''
|
description = ''
|
||||||
Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
|
Hostnames the Paseo daemon accepts in the Host header (DNS rebinding protection).
|
||||||
Localhost and IP addresses are always allowed by default.
|
Localhost and IP addresses are always allowed by default.
|
||||||
|
|
||||||
Use a leading dot to match a domain and all its subdomains
|
Use a leading dot to match a domain and all its subdomains
|
||||||
@@ -141,10 +145,10 @@ in
|
|||||||
"/run/wrappers/bin"
|
"/run/wrappers/bin"
|
||||||
"/nix/var/nix/profiles/default/bin"
|
"/nix/var/nix/profiles/default/bin"
|
||||||
]);
|
]);
|
||||||
} // lib.optionalAttrs (cfg.allowedHosts == true) {
|
} // lib.optionalAttrs (cfg.hostnames == true) {
|
||||||
PASEO_ALLOWED_HOSTS = "true";
|
PASEO_HOSTNAMES = "true";
|
||||||
} // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
|
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
|
||||||
PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
|
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
|
||||||
} // cfg.environment;
|
} // cfg.environment;
|
||||||
|
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
|
|||||||
+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-AyhJwa2ISs3wxorZIWblYasH35K3YpesPl4+1f5QTJg=";
|
npmDepsHash = "sha256-Q8FCZ5cQs45ZL0XeaRzgZXmROmywT0YI/1Rqdlpbt/w=";
|
||||||
|
|
||||||
# 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
+96
-123
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.55",
|
"version": "0.1.59",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.55",
|
"version": "0.1.59",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -21,7 +21,10 @@
|
|||||||
],
|
],
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.8",
|
"@biomejs/biome": "^2.4.8",
|
||||||
@@ -29,8 +32,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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -9547,9 +9550,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-native/assets-registry": {
|
"node_modules/@react-native/assets-registry": {
|
||||||
"version": "0.81.6",
|
"version": "0.81.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.6.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
||||||
"integrity": "sha512-nNlJ7mdXFoq/7LMG3eJIncqjgXkpDJak3xO8Lb4yQmFI3XVI1nupPRjlYRY0ham1gLE0F/AWvKFChsKUfF5lOQ==",
|
"integrity": "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 20.19.4"
|
"node": ">= 20.19.4"
|
||||||
@@ -9701,12 +9704,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-native/community-cli-plugin": {
|
"node_modules/@react-native/community-cli-plugin": {
|
||||||
"version": "0.81.6",
|
"version": "0.81.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.81.6.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.81.5.tgz",
|
||||||
"integrity": "sha512-oTwIheF4TU7NkfoHxwSQAKtIDx4SQEs2xufgM3gguY7WkpnhGa/BYA/A+hdHXfqEKJFKlHcXQu4BrV/7Sv1fhw==",
|
"integrity": "sha512-yWRlmEOtcyvSZ4+OvqPabt+NS36vg0K/WADTQLhrYrm9qdZSuXmq8PmdJWz/68wAqKQ+4KTILiq2kjRQwnyhQw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-native/dev-middleware": "0.81.6",
|
"@react-native/dev-middleware": "0.81.5",
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.4.0",
|
||||||
"invariant": "^2.2.4",
|
"invariant": "^2.2.4",
|
||||||
"metro": "^0.83.1",
|
"metro": "^0.83.1",
|
||||||
@@ -9730,46 +9733,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-native/community-cli-plugin/node_modules/@react-native/debugger-frontend": {
|
|
||||||
"version": "0.81.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.81.6.tgz",
|
|
||||||
"integrity": "sha512-aGw28yzbtm25GQuuxNeVAT72tLuGoH0yh79uYOIZkvjI+5x1NjZyPrgiLZ2LlZi5dJdxfbz30p1zUcHvcAzEZw==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20.19.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@react-native/community-cli-plugin/node_modules/@react-native/dev-middleware": {
|
|
||||||
"version": "0.81.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.81.6.tgz",
|
|
||||||
"integrity": "sha512-mK2M3gJ25LtgtqxS1ZXe1vHrz8APOA79Ot/MpbLeovFgLu6YJki0kbO5MRpJagTd+HbesVYSZb/BhAsGN7QAXA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@isaacs/ttlcache": "^1.4.1",
|
|
||||||
"@react-native/debugger-frontend": "0.81.6",
|
|
||||||
"chrome-launcher": "^0.15.2",
|
|
||||||
"chromium-edge-launcher": "^0.2.0",
|
|
||||||
"connect": "^3.6.5",
|
|
||||||
"debug": "^4.4.0",
|
|
||||||
"invariant": "^2.2.4",
|
|
||||||
"nullthrows": "^1.1.1",
|
|
||||||
"open": "^7.0.3",
|
|
||||||
"serve-static": "^1.16.2",
|
|
||||||
"ws": "^6.2.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20.19.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@react-native/community-cli-plugin/node_modules/ws": {
|
|
||||||
"version": "6.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz",
|
|
||||||
"integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"async-limiter": "~1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@react-native/debugger-frontend": {
|
"node_modules/@react-native/debugger-frontend": {
|
||||||
"version": "0.81.5",
|
"version": "0.81.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.81.5.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.81.5.tgz",
|
||||||
@@ -9811,18 +9774,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-native/gradle-plugin": {
|
"node_modules/@react-native/gradle-plugin": {
|
||||||
"version": "0.81.6",
|
"version": "0.81.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.81.6.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.81.5.tgz",
|
||||||
"integrity": "sha512-atUItC5MZ6yaNaI0sbsoDwUdF+KMNZcMKBIrNhXlUyIj3x1AQ6Cf8CHHv6Qokn8ZFw+uU6GWmQSiOWYUbmi8Ag==",
|
"integrity": "sha512-hORRlNBj+ReNMLo9jme3yQ6JQf4GZpVEBLxmTXGGlIL78MAezDZr5/uq9dwElSbcGmLEgeiax6e174Fie6qPLg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 20.19.4"
|
"node": ">= 20.19.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-native/js-polyfills": {
|
"node_modules/@react-native/js-polyfills": {
|
||||||
"version": "0.81.6",
|
"version": "0.81.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.6.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.5.tgz",
|
||||||
"integrity": "sha512-P5MWH/9vM24XkJ1TasCq42DMLoCUjZVSppTn6VWv/cI65NDjuYEy7bUSaXbYxGTnqiKyPG5Y+ADymqlIkdSAcw==",
|
"integrity": "sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 20.19.4"
|
"node": ">= 20.19.4"
|
||||||
@@ -9841,9 +9804,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@react-native/virtualized-lists": {
|
"node_modules/@react-native/virtualized-lists": {
|
||||||
"version": "0.81.6",
|
"version": "0.81.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.6.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz",
|
||||||
"integrity": "sha512-1RrZl3a7iCoAS2SGaRLjJPIn8bg/GLNXzqkIB2lufXcJsftu1umNLRIi17ZoDRejAWSd2pUfUtQBASo4R2mw4Q==",
|
"integrity": "sha512-UVXgV/db25OPIvwZySeToXD/9sKKhOdkcWmmf4Jh8iBZuyfML+/5CasaZ1E7Lqg6g3uqVQq75NqIwkYmORJMPw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"invariant": "^2.2.4",
|
"invariant": "^2.2.4",
|
||||||
@@ -9853,7 +9816,7 @@
|
|||||||
"node": ">= 20.19.4"
|
"node": ">= 20.19.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.1.4",
|
"@types/react": "^19.1.0",
|
||||||
"react": "*",
|
"react": "*",
|
||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
},
|
},
|
||||||
@@ -29159,9 +29122,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.1.4",
|
"version": "19.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||||
"integrity": "sha512-DHINL3PAmPUiK1uszfbKiXqfE03eszdt5BpVSuEAHb5nfmNPwnsy7g39h2t8aXFc/Bv99GH81s+j8dobtD+jOw==",
|
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -29199,15 +29162,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.1.4",
|
"version": "19.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||||
"integrity": "sha512-s2868ab/xo2SI6H4106A7aFI8Mrqa4xC6HZT/pBzYyQ3cBLqa88hu47xYD8xf+uECleN698Awn7RCWlkTiKnqQ==",
|
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.26.0"
|
"scheduler": "^0.26.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^19.1.4"
|
"react": "^19.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-error-boundary": {
|
"node_modules/react-error-boundary": {
|
||||||
@@ -29279,19 +29242,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-native": {
|
"node_modules/react-native": {
|
||||||
"version": "0.81.6",
|
"version": "0.81.5",
|
||||||
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.6.tgz",
|
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz",
|
||||||
"integrity": "sha512-X/tI8GqfzVaa+zfbE4+lySNN5UzwBIAVRHVZPKymOny9Acc5GYYcjAcEVfG3AM4h920YALWoSl8favnDmQEWIg==",
|
"integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jest/create-cache-key-function": "^29.7.0",
|
"@jest/create-cache-key-function": "^29.7.0",
|
||||||
"@react-native/assets-registry": "0.81.6",
|
"@react-native/assets-registry": "0.81.5",
|
||||||
"@react-native/codegen": "0.81.6",
|
"@react-native/codegen": "0.81.5",
|
||||||
"@react-native/community-cli-plugin": "0.81.6",
|
"@react-native/community-cli-plugin": "0.81.5",
|
||||||
"@react-native/gradle-plugin": "0.81.6",
|
"@react-native/gradle-plugin": "0.81.5",
|
||||||
"@react-native/js-polyfills": "0.81.6",
|
"@react-native/js-polyfills": "0.81.5",
|
||||||
"@react-native/normalize-colors": "0.81.6",
|
"@react-native/normalize-colors": "0.81.5",
|
||||||
"@react-native/virtualized-lists": "0.81.6",
|
"@react-native/virtualized-lists": "0.81.5",
|
||||||
"abort-controller": "^3.0.0",
|
"abort-controller": "^3.0.0",
|
||||||
"anser": "^1.4.9",
|
"anser": "^1.4.9",
|
||||||
"ansi-regex": "^5.0.0",
|
"ansi-regex": "^5.0.0",
|
||||||
@@ -29326,8 +29289,8 @@
|
|||||||
"node": ">= 20.19.4"
|
"node": ">= 20.19.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.1.4",
|
"@types/react": "^19.1.0",
|
||||||
"react": "^19.1.4"
|
"react": "^19.1.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@types/react": {
|
"@types/react": {
|
||||||
@@ -29670,31 +29633,16 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-native/node_modules/@react-native/codegen": {
|
"node_modules/react-native/node_modules/@react-native/normalize-colors": {
|
||||||
"version": "0.81.6",
|
"version": "0.81.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.81.6.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz",
|
||||||
"integrity": "sha512-9KoYRep/KDnELLLmIYTtIIEOClVUJ88pxWObb/0sjkacA7uL4SgfbAg7rWLURAQJWI85L1YS67IhdEqNNk1I7w==",
|
"integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"dependencies": {
|
|
||||||
"@babel/core": "^7.25.2",
|
|
||||||
"@babel/parser": "^7.25.3",
|
|
||||||
"glob": "^7.1.1",
|
|
||||||
"hermes-parser": "0.29.1",
|
|
||||||
"invariant": "^2.2.4",
|
|
||||||
"nullthrows": "^1.1.1",
|
|
||||||
"yargs": "^17.6.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20.19.4"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@babel/core": "*"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/react-native/node_modules/brace-expansion": {
|
"node_modules/react-native/node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0",
|
"balanced-match": "^1.0.0",
|
||||||
@@ -34906,16 +34854,16 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.1.55",
|
"version": "0.1.59",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@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.55",
|
"@getpaseo/expo-two-way-audio": "0.1.59",
|
||||||
"@getpaseo/highlight": "0.1.55",
|
"@getpaseo/highlight": "0.1.59",
|
||||||
"@getpaseo/server": "0.1.55",
|
"@getpaseo/server": "0.1.59",
|
||||||
"@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",
|
||||||
@@ -34963,8 +34911,8 @@
|
|||||||
"expo-web-browser": "~15.0.8",
|
"expo-web-browser": "~15.0.8",
|
||||||
"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",
|
||||||
@@ -35056,11 +35004,11 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.1.55",
|
"version": "0.1.59",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/relay": "0.1.55",
|
"@getpaseo/relay": "0.1.59",
|
||||||
"@getpaseo/server": "0.1.55",
|
"@getpaseo/server": "0.1.59",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -35101,11 +35049,11 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.1.55",
|
"version": "0.1.59",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "0.1.55",
|
"@getpaseo/cli": "0.1.59",
|
||||||
"@getpaseo/server": "0.1.55",
|
"@getpaseo/server": "0.1.59",
|
||||||
"electron-log": "^5.4.3",
|
"electron-log": "^5.4.3",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
@@ -35139,7 +35087,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.1.55",
|
"version": "0.1.59",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
@@ -35340,7 +35288,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.1.55",
|
"version": "0.1.59",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@lezer/cpp": "^1.1.5",
|
"@lezer/cpp": "^1.1.5",
|
||||||
@@ -35366,7 +35314,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.1.55",
|
"version": "0.1.59",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -35382,14 +35330,14 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.1.55",
|
"version": "0.1.59",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@ai-sdk/openai": "2.0.52",
|
"@ai-sdk/openai": "2.0.52",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||||
"@deepgram/sdk": "^3.4.0",
|
"@deepgram/sdk": "^3.4.0",
|
||||||
"@getpaseo/highlight": "0.1.55",
|
"@getpaseo/highlight": "0.1.59",
|
||||||
"@getpaseo/relay": "0.1.55",
|
"@getpaseo/relay": "0.1.59",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||||
"@opencode-ai/sdk": "1.2.6",
|
"@opencode-ai/sdk": "1.2.6",
|
||||||
@@ -35417,6 +35365,7 @@
|
|||||||
"strip-ansi": "^7.1.2",
|
"strip-ansi": "^7.1.2",
|
||||||
"tiny-invariant": "^1.3.3",
|
"tiny-invariant": "^1.3.3",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
|
"which": "^5.0.0",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
"zod": "^3.23.8",
|
"zod": "^3.23.8",
|
||||||
"zod-to-json-schema": "^3.25.1"
|
"zod-to-json-schema": "^3.25.1"
|
||||||
@@ -35640,6 +35589,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/server/node_modules/isexe": {
|
||||||
|
"version": "3.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
|
||||||
|
"integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/server/node_modules/media-typer": {
|
"packages/server/node_modules/media-typer": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||||
@@ -35794,6 +35752,21 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/server/node_modules/which": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^3.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-which": "bin/which.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || >=20.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/server/node_modules/yocto-queue": {
|
"packages/server/node_modules/yocto-queue": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
|
||||||
@@ -35817,7 +35790,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.1.55",
|
"version": "0.1.59",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.20.3",
|
"@cloudflare/vite-plugin": "^1.20.3",
|
||||||
"@cloudflare/workers-types": "^4.20260114.0",
|
"@cloudflare/workers-types": "^4.20260114.0",
|
||||||
|
|||||||
+10
-6
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.1.55",
|
"version": "0.1.59",
|
||||||
"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",
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,6 +194,16 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -254,13 +270,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,195 @@
|
|||||||
|
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").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-"])',
|
||||||
|
);
|
||||||
|
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"]',
|
||||||
|
)
|
||||||
|
.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");
|
||||||
|
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");
|
||||||
|
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").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").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");
|
||||||
|
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");
|
||||||
|
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 })
|
||||||
|
.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");
|
||||||
|
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();
|
||||||
@@ -179,42 +164,36 @@ 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createButton = page
|
||||||
|
.getByTestId("message-input-root")
|
||||||
|
.getByRole("button", { name: "Create" });
|
||||||
|
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||||
|
await createButton.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -22,6 +22,28 @@ export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise<string[]> {
|
||||||
|
const allTabIds = await getWorkspaceTabTestIds(page);
|
||||||
|
return allTabIds.filter((id) => id.startsWith("workspace-tab-agent_"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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(page.getByTestId(`workspace-tab-agent_${expectedId}`)).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,6 +84,21 @@ 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 },
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
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";
|
||||||
|
|
||||||
@@ -10,10 +10,15 @@ type TempRepo = {
|
|||||||
|
|
||||||
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 }>;
|
||||||
|
},
|
||||||
): 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,7 +27,24 @@ 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" });
|
||||||
|
|
||||||
if (withRemote) {
|
if (withRemote) {
|
||||||
|
|||||||
@@ -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,6 @@
|
|||||||
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,
|
||||||
@@ -11,8 +12,11 @@ import {
|
|||||||
} from "./helpers/new-workspace";
|
} from "./helpers/new-workspace";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
import {
|
import {
|
||||||
|
expectSidebarWorkspaceSelected,
|
||||||
expectWorkspaceHeader,
|
expectWorkspaceHeader,
|
||||||
switchWorkspaceViaSidebar,
|
switchWorkspaceViaSidebar,
|
||||||
|
waitForSidebarHydration,
|
||||||
|
waitForWorkspaceInSidebar,
|
||||||
workspaceLabelFromPath,
|
workspaceLabelFromPath,
|
||||||
} from "./helpers/workspace-ui";
|
} from "./helpers/workspace-ui";
|
||||||
|
|
||||||
@@ -21,7 +25,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 +60,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 +78,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 +94,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 +102,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 +197,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 +240,13 @@ test.describe("New workspace flow", () => {
|
|||||||
subtitle: openedProject.projectDisplayName,
|
subtitle: openedProject.projectDisplayName,
|
||||||
});
|
});
|
||||||
|
|
||||||
const draftTabs = page.locator('[data-testid^="workspace-tab-"]').filter({
|
const agentTabs = page.locator('[data-testid^="workspace-tab-agent_"]');
|
||||||
has: page.getByText("New Agent", { exact: true }),
|
await expect(agentTabs).toHaveCount(1, { timeout: 30_000 });
|
||||||
});
|
|
||||||
await expect(draftTabs).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 });
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 as a title with a pencil edit affordance; the input is hidden until edit.
|
||||||
|
await expect(page.getByTestId("host-page-label-card")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("host-page-label-card").getByText(TEST_HOST_LABEL, { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Desktop detail pane shows a ScreenHeader with the host label.
|
||||||
|
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(TEST_HOST_LABEL);
|
||||||
|
|
||||||
|
// 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 expect(page.getByTestId("host-page-label-card")).toBeVisible();
|
||||||
|
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,178 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("host-page-label-card")).toHaveCount(0);
|
||||||
|
|
||||||
|
// Root shows the menu header, not a back button.
|
||||||
|
await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 expect(page).toHaveURL(/\/settings$/);
|
||||||
|
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
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,164 @@
|
|||||||
|
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}`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 page.reload();
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
await expectSidebarWorkspaceSelected({
|
||||||
|
page,
|
||||||
|
serverId,
|
||||||
|
workspaceId: secondWorkspace.workspaceId,
|
||||||
|
});
|
||||||
|
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}`,
|
||||||
|
]);
|
||||||
|
} 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").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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"version": "0.1.55",
|
"version": "0.1.59",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
@@ -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.55",
|
"@getpaseo/expo-two-way-audio": "0.1.59",
|
||||||
"@getpaseo/highlight": "0.1.55",
|
"@getpaseo/highlight": "0.1.59",
|
||||||
"@getpaseo/server": "0.1.55",
|
"@getpaseo/server": "0.1.59",
|
||||||
"@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",
|
||||||
@@ -81,8 +81,8 @@
|
|||||||
"expo-web-browser": "~15.0.8",
|
"expo-web-browser": "~15.0.8",
|
||||||
"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",
|
||||||
|
|||||||
@@ -51,6 +51,17 @@
|
|||||||
[contenteditable='true'] {
|
[contenteditable='true'] {
|
||||||
-webkit-app-region: no-drag !important;
|
-webkit-app-region: no-drag !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Suppress the browser's default focus outline — it appears on mouse
|
||||||
|
clicks and looks out of place against our themed surfaces. Keep a
|
||||||
|
themed ring for keyboard users via :focus-visible. */
|
||||||
|
*:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid #20744A;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ 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 { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||||
import { queryClient } from "@/query/query-client";
|
import { queryClient } from "@/query/query-client";
|
||||||
import {
|
import {
|
||||||
@@ -79,6 +80,7 @@ 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 { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||||
import { isWeb, isNative } from "@/constants/platform";
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
@@ -435,7 +437,6 @@ function AppContainer({
|
|||||||
enabled: chromeEnabled,
|
enabled: chromeEnabled,
|
||||||
isMobile: isCompactLayout,
|
isMobile: isCompactLayout,
|
||||||
toggleAgentList,
|
toggleAgentList,
|
||||||
selectedAgentId,
|
|
||||||
toggleFileExplorer,
|
toggleFileExplorer,
|
||||||
toggleBothSidebars,
|
toggleBothSidebars,
|
||||||
toggleFocusMode,
|
toggleFocusMode,
|
||||||
@@ -460,6 +461,7 @@ function AppContainer({
|
|||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<CommandCenter />
|
<CommandCenter />
|
||||||
<ProjectPickerModal />
|
<ProjectPickerModal />
|
||||||
|
<WorkspaceSetupDialog />
|
||||||
<KeyboardShortcutsDialog />
|
<KeyboardShortcutsDialog />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -788,7 +790,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,7 +802,8 @@ 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
|
<Stack.Screen
|
||||||
@@ -818,6 +820,7 @@ function RootStack() {
|
|||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-bo
|
|||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
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 +32,21 @@ function HostAgentReadyRouteContent() {
|
|||||||
}
|
}
|
||||||
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
|
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
|
||||||
});
|
});
|
||||||
|
const sessionWorkspaces = useSessionStore((state) =>
|
||||||
|
serverId ? state.sessions[serverId]?.workspaces : undefined,
|
||||||
|
);
|
||||||
|
const hasHydratedWorkspaces = useSessionStore((state) =>
|
||||||
|
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
||||||
|
);
|
||||||
|
const resolvedWorkspaceId = useSessionStore((state) => {
|
||||||
|
if (!serverId || !agentId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resolveWorkspaceIdByExecutionDirectory({
|
||||||
|
workspaces: state.sessions[serverId]?.workspaces?.values(),
|
||||||
|
workspaceDirectory: state.sessions[serverId]?.agents?.get(agentId)?.cwd,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (redirectedRef.current) {
|
if (redirectedRef.current) {
|
||||||
@@ -42,18 +58,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 +77,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 +102,19 @@ function HostAgentReadyRouteContent() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cwd = result?.agent?.cwd?.trim();
|
const cwd = result?.agent?.cwd?.trim();
|
||||||
|
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||||
|
workspaces: sessionWorkspaces?.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 +133,7 @@ function HostAgentReadyRouteContent() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [agentId, client, isConnected, router, serverId]);
|
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId, sessionWorkspaces]);
|
||||||
|
|
||||||
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,16 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
import { useNavigation } from "@react-navigation/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 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 +39,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 +77,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);
|
||||||
@@ -71,6 +106,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,16 +130,15 @@ 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;
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -14,8 +14,39 @@ import {
|
|||||||
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 { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
|
type EscHandler = () => void;
|
||||||
|
const escStack: EscHandler[] = [];
|
||||||
|
let escListenerAttached = false;
|
||||||
|
|
||||||
|
function handleEscKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
const top = escStack[escStack.length - 1];
|
||||||
|
if (!top) return;
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
top();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushEscHandler(handler: EscHandler): () => void {
|
||||||
|
escStack.push(handler);
|
||||||
|
if (!escListenerAttached && typeof window !== "undefined") {
|
||||||
|
window.addEventListener("keydown", handleEscKeyDown, true);
|
||||||
|
escListenerAttached = true;
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
const index = escStack.lastIndexOf(handler);
|
||||||
|
if (index !== -1) escStack.splice(index, 1);
|
||||||
|
if (escStack.length === 0 && escListenerAttached && typeof window !== "undefined") {
|
||||||
|
window.removeEventListener("keydown", handleEscKeyDown, true);
|
||||||
|
escListenerAttached = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
desktopOverlay: {
|
desktopOverlay: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
@@ -36,22 +67,34 @@ 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: {
|
||||||
color: theme.colors.foreground,
|
flex: 1,
|
||||||
fontSize: theme.fontSize.lg,
|
fontSize: theme.fontSize.lg,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.medium,
|
||||||
},
|
},
|
||||||
|
headerActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
marginLeft: theme.spacing[3],
|
||||||
|
marginRight: theme.spacing[2],
|
||||||
|
},
|
||||||
closeButton: {
|
closeButton: {
|
||||||
padding: theme.spacing[2],
|
padding: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
@@ -75,14 +118,27 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
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],
|
||||||
gap: theme.spacing[4],
|
gap: theme.spacing[4],
|
||||||
},
|
},
|
||||||
|
bottomSheetStaticContent: {
|
||||||
|
flex: 1,
|
||||||
|
padding: theme.spacing[6],
|
||||||
|
gap: theme.spacing[4],
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
|
desktopStaticContent: {
|
||||||
|
flexShrink: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
padding: theme.spacing[6],
|
||||||
|
gap: theme.spacing[4],
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||||
@@ -103,22 +159,35 @@ 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;
|
||||||
snapPoints?: string[];
|
snapPoints?: string[];
|
||||||
stackBehavior?: "push" | "switch" | "replace";
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdaptiveModalSheet({
|
export function AdaptiveModalSheet({
|
||||||
title,
|
title,
|
||||||
|
subtitle,
|
||||||
visible,
|
visible,
|
||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
|
headerActions,
|
||||||
snapPoints,
|
snapPoints,
|
||||||
stackBehavior,
|
stackBehavior,
|
||||||
testID,
|
testID,
|
||||||
|
desktopMaxWidth,
|
||||||
|
onFilesDropped,
|
||||||
|
scrollable = true,
|
||||||
}: AdaptiveModalSheetProps) {
|
}: AdaptiveModalSheetProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
@@ -157,6 +226,11 @@ export function AdaptiveModalSheet({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWeb || isMobile || !visible) return;
|
||||||
|
return pushEscHandler(onClose);
|
||||||
|
}, [visible, isMobile, onClose]);
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<BottomSheetModal
|
<BottomSheetModal
|
||||||
@@ -172,38 +246,50 @@ export function AdaptiveModalSheet({
|
|||||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
handleIndicatorStyle={styles.bottomSheetHandle}
|
||||||
keyboardBehavior="extend"
|
keyboardBehavior="extend"
|
||||||
keyboardBlurBehavior="restore"
|
keyboardBlurBehavior="restore"
|
||||||
|
accessible={false}
|
||||||
>
|
>
|
||||||
<View style={styles.bottomSheetHeader}>
|
<View style={styles.bottomSheetHeader} testID={testID}>
|
||||||
<Text style={styles.title}>{title}</Text>
|
<View style={styles.headerTitleGroup}>
|
||||||
|
<Text style={[styles.title, { color: theme.colors.foreground }]} numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{subtitle}
|
||||||
|
</View>
|
||||||
|
{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} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<BottomSheetScrollView
|
{scrollable ? (
|
||||||
contentContainerStyle={styles.bottomSheetContent}
|
<BottomSheetScrollView
|
||||||
keyboardShouldPersistTaps="handled"
|
contentContainerStyle={styles.bottomSheetContent}
|
||||||
showsVerticalScrollIndicator={false}
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
showsVerticalScrollIndicator={false}
|
||||||
{children}
|
>
|
||||||
</BottomSheetScrollView>
|
{children}
|
||||||
|
</BottomSheetScrollView>
|
||||||
|
) : (
|
||||||
|
<View style={styles.bottomSheetStaticContent}>{children}</View>
|
||||||
|
)}
|
||||||
</BottomSheetModal>
|
</BottomSheetModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const desktopContent = (
|
const cardInner = (
|
||||||
<View style={styles.desktopOverlay} testID={testID}>
|
<>
|
||||||
<Pressable
|
<View style={styles.header}>
|
||||||
accessibilityLabel="Dismiss"
|
<View style={styles.headerTitleGroup}>
|
||||||
style={{ ...StyleSheet.absoluteFillObject }}
|
<Text style={[styles.title, { color: theme.colors.foreground }]} numberOfLines={1}>
|
||||||
onPress={onClose}
|
{title}
|
||||||
/>
|
</Text>
|
||||||
<View style={styles.desktopCard}>
|
{subtitle}
|
||||||
<View style={styles.header}>
|
|
||||||
<Text style={styles.title}>{title}</Text>
|
|
||||||
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
|
||||||
<X size={16} color={theme.colors.foregroundMuted} />
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
</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
|
<ScrollView
|
||||||
style={styles.desktopScroll}
|
style={styles.desktopScroll}
|
||||||
contentContainerStyle={styles.desktopContent}
|
contentContainerStyle={styles.desktopContent}
|
||||||
@@ -212,6 +298,25 @@ export function AdaptiveModalSheet({
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
<View style={styles.desktopStaticContent}>{children}</View>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const desktopContent = (
|
||||||
|
<View style={styles.desktopOverlay} testID={testID}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityLabel="Dismiss"
|
||||||
|
style={{ ...StyleSheet.absoluteFillObject }}
|
||||||
|
onPress={onClose}
|
||||||
|
/>
|
||||||
|
<View style={[styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }]}>
|
||||||
|
{onFilesDropped ? (
|
||||||
|
<FileDropZone onFilesDropped={onFilesDropped}>{cardInner}</FileDropZone>
|
||||||
|
) : (
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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,10 @@ 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(setSendError).toHaveBeenCalledWith(null);
|
||||||
expect(setIsProcessing).toHaveBeenCalledWith(true);
|
expect(setIsProcessing).toHaveBeenCalledWith(true);
|
||||||
expect(clearDraft).not.toHaveBeenCalled();
|
expect(clearDraft).not.toHaveBeenCalled();
|
||||||
@@ -63,21 +64,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 +86,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 +104,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 +130,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,35 @@
|
|||||||
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;
|
||||||
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;
|
||||||
|
|
||||||
if (!trimmedMessage && !imageAttachments?.length) {
|
if (
|
||||||
|
!trimmedMessage &&
|
||||||
|
attachments.length === 0 &&
|
||||||
|
!input.hasExternalContent &&
|
||||||
|
!input.allowEmptySubmit
|
||||||
|
) {
|
||||||
return "noop";
|
return "noop";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,27 +38,27 @@ 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("");
|
input.setUserInput("");
|
||||||
input.setSelectedImages([]);
|
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("");
|
input.setUserInput("");
|
||||||
input.setSelectedImages([]);
|
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);
|
input.setUserInput(trimmedMessage);
|
||||||
input.setSelectedImages(imageAttachments ?? []);
|
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",
|
||||||
|
|||||||
@@ -879,7 +879,7 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
isLoading: snapshotIsLoading,
|
isLoading: snapshotIsLoading,
|
||||||
isFetching: snapshotIsFetching,
|
isFetching: snapshotIsFetching,
|
||||||
invalidate: invalidateSnapshot,
|
invalidate: invalidateSnapshot,
|
||||||
} = useProvidersSnapshot(serverId);
|
} = useProvidersSnapshot(serverId, agent?.cwd);
|
||||||
|
|
||||||
const snapshotModels = useMemo(() => {
|
const snapshotModels = useMemo(() => {
|
||||||
if (!snapshotEntries || !agent?.provider) {
|
if (!snapshotEntries || !agent?.provider) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { View, Text, ScrollView, Pressable, Modal } from "react-native";
|
|||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
|
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||||
|
|
||||||
export interface Artifact {
|
export interface Artifact {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -147,6 +148,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
||||||
|
const webScrollbarStyle = useWebScrollbarStyle();
|
||||||
|
|
||||||
if (!artifact) {
|
if (!artifact) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -190,7 +193,7 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
|||||||
</View>
|
</View>
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.contentScroll}
|
style={[styles.contentScroll, webScrollbarStyle]}
|
||||||
contentContainerStyle={styles.contentScrollContainer}
|
contentContainerStyle={styles.contentScrollContainer}
|
||||||
>
|
>
|
||||||
{artifact.type === "image" ? (
|
{artifact.type === "image" ? (
|
||||||
@@ -200,7 +203,11 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.codeContainer}>
|
<View style={styles.codeContainer}>
|
||||||
<ScrollView horizontal showsHorizontalScrollIndicator={true}>
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={true}
|
||||||
|
style={webScrollbarStyle}
|
||||||
|
>
|
||||||
<Text style={styles.codeText}>{content}</Text>
|
<Text style={styles.codeText}>{content}</Text>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -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,87 @@
|
|||||||
|
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;
|
||||||
|
testID?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachmentPill({
|
||||||
|
onOpen,
|
||||||
|
onRemove,
|
||||||
|
openAccessibilityLabel,
|
||||||
|
removeAccessibilityLabel,
|
||||||
|
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}
|
||||||
|
onHoverIn={() => setIsBodyHovered(true)}
|
||||||
|
onHoverOut={() => setIsBodyHovered(false)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={openAccessibilityLabel}
|
||||||
|
style={styles.body}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Pressable>
|
||||||
|
<Pressable
|
||||||
|
onPress={onRemove}
|
||||||
|
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;
|
||||||
@@ -43,12 +44,15 @@ export function BranchSwitcher({
|
|||||||
queryClient,
|
queryClient,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const titleContent = (
|
||||||
|
<>
|
||||||
|
{isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null}
|
||||||
|
<ScreenTitle testID="workspace-header-title">{title}</ScreenTitle>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
if (!currentBranchName) {
|
if (!currentBranchName) {
|
||||||
return (
|
return <View style={styles.branchSwitcherTrigger}>{titleContent}</View>;
|
||||||
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,10 +67,7 @@ export function BranchSwitcher({
|
|||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
|
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
|
||||||
>
|
>
|
||||||
<GitBranch size={14} color={theme.colors.foregroundMuted} />
|
{titleContent}
|
||||||
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
{!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
|
{!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Combobox
|
<Combobox
|
||||||
@@ -100,15 +101,6 @@ 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",
|
||||||
@@ -117,7 +109,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
xs: -theme.spacing[2],
|
xs: -theme.spacing[2],
|
||||||
md: 0,
|
md: 0,
|
||||||
},
|
},
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: {
|
||||||
|
xs: 0,
|
||||||
|
md: theme.spacing[1],
|
||||||
|
},
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
|
|||||||
@@ -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,26 +556,24 @@ 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 ProviderIcon = getProviderIcon(selectedProvider);
|
||||||
const selectedProviderLabel = useMemo(
|
|
||||||
() => resolveProviderLabel(providerDefinitions, selectedProvider),
|
|
||||||
[providerDefinitions, selectedProvider],
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedModelLabel = useMemo(() => {
|
const selectedModelLabel = useMemo(() => {
|
||||||
|
if (!selectedModel) {
|
||||||
|
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";
|
||||||
@@ -586,8 +596,8 @@ export function CombinedModelSelector({
|
|||||||
return selectedModelLabel;
|
return selectedModelLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildSelectedTriggerLabel(selectedProviderLabel, selectedModelLabel);
|
return buildSelectedTriggerLabel(selectedModelLabel);
|
||||||
}, [selectedModelLabel, selectedProviderLabel]);
|
}, [selectedModelLabel]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (platformIsWeb) {
|
if (platformIsWeb) {
|
||||||
@@ -788,11 +798,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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+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", () => {
|
||||||
@@ -0,0 +1,887 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
||||||
|
import type { GitHubSearchItem } from "@server/shared/messages";
|
||||||
|
import { Composer } from "./composer";
|
||||||
|
import { splitComposerAttachmentsForSubmit } from "./composer-attachments";
|
||||||
|
|
||||||
|
const {
|
||||||
|
theme,
|
||||||
|
imageMetadata,
|
||||||
|
issueItem,
|
||||||
|
prItem,
|
||||||
|
mockClient,
|
||||||
|
pickImagesMock,
|
||||||
|
persistAttachmentFromBlobMock,
|
||||||
|
deleteAttachmentsMock,
|
||||||
|
encodeImagesMock,
|
||||||
|
openExternalUrlMock,
|
||||||
|
mockSessionState,
|
||||||
|
setAgentStreamTailMock,
|
||||||
|
setAgentStreamHeadMock,
|
||||||
|
setQueuedMessagesMock,
|
||||||
|
} = vi.hoisted(() => {
|
||||||
|
const theme = {
|
||||||
|
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
|
||||||
|
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, lg: 18 },
|
||||||
|
fontWeight: { normal: "400", medium: "500" },
|
||||||
|
shadow: { md: {} },
|
||||||
|
colors: {
|
||||||
|
surface0: "#000",
|
||||||
|
surface1: "#111",
|
||||||
|
surface2: "#222",
|
||||||
|
surface3: "#333",
|
||||||
|
surface4: "#888",
|
||||||
|
foreground: "#fff",
|
||||||
|
foregroundMuted: "#aaa",
|
||||||
|
popoverForeground: "#fff",
|
||||||
|
border: "#555",
|
||||||
|
borderAccent: "#444",
|
||||||
|
accent: "#0a84ff",
|
||||||
|
accentForeground: "#fff",
|
||||||
|
destructive: "#ff453a",
|
||||||
|
palette: {
|
||||||
|
green: { 500: "#30d158", 600: "#24b14c", 800: "#126024" },
|
||||||
|
red: { 500: "#ff453a", 600: "#d92d20" },
|
||||||
|
zinc: { 600: "#52525b" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageMetadata: AttachmentMetadata = {
|
||||||
|
id: "img-1",
|
||||||
|
mimeType: "image/png",
|
||||||
|
storageType: "web-indexeddb",
|
||||||
|
storageKey: "img-1",
|
||||||
|
fileName: "img-1.png",
|
||||||
|
byteSize: 42,
|
||||||
|
createdAt: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const issueItem: GitHubSearchItem = {
|
||||||
|
kind: "issue",
|
||||||
|
number: 101,
|
||||||
|
title: "Fix composer attachments",
|
||||||
|
url: "https://github.com/acme/paseo/issues/101",
|
||||||
|
state: "open",
|
||||||
|
body: "Issue body",
|
||||||
|
labels: ["composer"],
|
||||||
|
baseRefName: null,
|
||||||
|
headRefName: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const prItem: GitHubSearchItem = {
|
||||||
|
kind: "pr",
|
||||||
|
number: 202,
|
||||||
|
title: "Refactor composer attachments",
|
||||||
|
url: "https://github.com/acme/paseo/pull/202",
|
||||||
|
state: "open",
|
||||||
|
body: "PR body",
|
||||||
|
labels: ["composer"],
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "composer-attachments",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockClient = {
|
||||||
|
isConnected: true,
|
||||||
|
searchGitHub: vi.fn(async () => ({ items: [issueItem, prItem] })),
|
||||||
|
sendAgentMessage: vi.fn(async () => {}),
|
||||||
|
cancelAgent: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const setQueuedMessagesMock = vi.fn();
|
||||||
|
const mockSessionState: {
|
||||||
|
sessions: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
agents: Map<string, { status: string; lastUsage: null }>;
|
||||||
|
queuedMessages: Map<string, unknown[]>;
|
||||||
|
agentStreamHead: Map<string, unknown[]>;
|
||||||
|
agentStreamTail: Map<string, unknown[]>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
setQueuedMessages: ReturnType<typeof vi.fn>;
|
||||||
|
setAgentStreamTail?: ReturnType<typeof vi.fn>;
|
||||||
|
setAgentStreamHead?: ReturnType<typeof vi.fn>;
|
||||||
|
} = {
|
||||||
|
sessions: {
|
||||||
|
server: {
|
||||||
|
agents: new Map([["agent", { status: "idle", lastUsage: null }]]),
|
||||||
|
queuedMessages: new Map(),
|
||||||
|
agentStreamHead: new Map(),
|
||||||
|
agentStreamTail: new Map(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setQueuedMessages: setQueuedMessagesMock,
|
||||||
|
};
|
||||||
|
const setAgentStreamTailMock = vi.fn(
|
||||||
|
(serverId: string, updater: (prev: Map<string, unknown[]>) => Map<string, unknown[]>) => {
|
||||||
|
const session = mockSessionState.sessions[serverId];
|
||||||
|
session.agentStreamTail = updater(session.agentStreamTail);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const setAgentStreamHeadMock = vi.fn(
|
||||||
|
(serverId: string, updater: (prev: Map<string, unknown[]>) => Map<string, unknown[]>) => {
|
||||||
|
const session = mockSessionState.sessions[serverId];
|
||||||
|
session.agentStreamHead = updater(session.agentStreamHead);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
mockSessionState.setAgentStreamTail = setAgentStreamTailMock;
|
||||||
|
mockSessionState.setAgentStreamHead = setAgentStreamHeadMock;
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme,
|
||||||
|
imageMetadata,
|
||||||
|
issueItem,
|
||||||
|
prItem,
|
||||||
|
mockClient,
|
||||||
|
pickImagesMock: vi.fn(),
|
||||||
|
persistAttachmentFromBlobMock: vi.fn(async () => imageMetadata),
|
||||||
|
deleteAttachmentsMock: vi.fn(async () => {}),
|
||||||
|
encodeImagesMock: vi.fn(async (images: AttachmentMetadata[]) => images),
|
||||||
|
openExternalUrlMock: vi.fn(async () => {}),
|
||||||
|
mockSessionState,
|
||||||
|
setAgentStreamTailMock,
|
||||||
|
setAgentStreamHeadMock,
|
||||||
|
setQueuedMessagesMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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("@/constants/layout", () => ({
|
||||||
|
FOOTER_HEIGHT: 72,
|
||||||
|
MAX_CONTENT_WIDTH: 900,
|
||||||
|
useIsCompactFormFactor: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("lucide-react-native", () => {
|
||||||
|
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||||
|
React.createElement("span", { ...props, "data-icon": name });
|
||||||
|
return {
|
||||||
|
ArrowUp: createIcon("ArrowUp"),
|
||||||
|
Square: createIcon("Square"),
|
||||||
|
Pencil: createIcon("Pencil"),
|
||||||
|
AudioLines: createIcon("AudioLines"),
|
||||||
|
CircleDot: createIcon("CircleDot"),
|
||||||
|
GitPullRequest: createIcon("GitPullRequest"),
|
||||||
|
X: createIcon("X"),
|
||||||
|
Mic: createIcon("Mic"),
|
||||||
|
MicOff: createIcon("MicOff"),
|
||||||
|
Plus: createIcon("Plus"),
|
||||||
|
Paperclip: createIcon("Paperclip"),
|
||||||
|
Github: createIcon("Github"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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("react-native-safe-area-context", () => ({
|
||||||
|
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/runtime/host-runtime", () => ({
|
||||||
|
useHostRuntimeClient: () => mockClient,
|
||||||
|
useHostRuntimeIsConnected: () => true,
|
||||||
|
useHostRuntimeAgentDirectoryStatus: () => "ready",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/stores/session-store", () => {
|
||||||
|
const useSessionStore = (selector: (state: typeof mockSessionState) => unknown) =>
|
||||||
|
selector(mockSessionState);
|
||||||
|
useSessionStore.getState = () => mockSessionState;
|
||||||
|
return { useSessionStore };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-image-attachment-picker", () => ({
|
||||||
|
useImageAttachmentPicker: () => ({ pickImages: pickImagesMock }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/attachments/service", () => ({
|
||||||
|
persistAttachmentFromBlob: persistAttachmentFromBlobMock,
|
||||||
|
persistAttachmentFromFileUri: vi.fn(async () => imageMetadata),
|
||||||
|
deleteAttachments: deleteAttachmentsMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/attachments/use-attachment-preview-url", () => ({
|
||||||
|
useAttachmentPreviewUrl: () => "blob:preview",
|
||||||
|
}));
|
||||||
|
|
||||||
|
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,
|
||||||
|
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("@/utils/encode-images", () => ({
|
||||||
|
encodeImages: encodeImagesMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/utils/open-external-url", () => ({
|
||||||
|
openExternalUrl: openExternalUrlMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-settings", () => ({
|
||||||
|
useAppSettings: () => ({ settings: { sendBehavior: "interrupt" } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-agent-autocomplete", () => ({
|
||||||
|
useAgentAutocomplete: () => ({
|
||||||
|
isVisible: false,
|
||||||
|
options: [],
|
||||||
|
selectedIndex: -1,
|
||||||
|
isLoading: false,
|
||||||
|
errorMessage: null,
|
||||||
|
loadingText: "",
|
||||||
|
emptyText: "",
|
||||||
|
onSelectOption: vi.fn(),
|
||||||
|
onKeyPress: () => false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-shortcut-keys", () => ({
|
||||||
|
useShortcutKeys: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-keyboard-action-handler", () => ({
|
||||||
|
useKeyboardActionHandler: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
|
||||||
|
useKeyboardShiftStyle: () => ({ style: {} }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/contexts/voice-context", () => ({
|
||||||
|
useVoiceOptional: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/contexts/toast-context", () => ({
|
||||||
|
useToast: () => ({ error: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/utils/scroll-jank-investigation", () => ({
|
||||||
|
markScrollInvestigationRender: vi.fn(),
|
||||||
|
markScrollInvestigationEvent: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/agent-status-bar", () => ({
|
||||||
|
AgentStatusBar: () => null,
|
||||||
|
DraftAgentStatusBar: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/context-window-meter", () => ({
|
||||||
|
ContextWindowMeter: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/composer.status-controls", () => ({
|
||||||
|
resolveStatusControlMode: () => "agent",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/autocomplete", () => ({
|
||||||
|
Autocomplete: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/use-web-scrollbar", () => ({
|
||||||
|
useWebElementScrollbar: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-web-scrollbar-style", () => ({
|
||||||
|
useWebScrollbarStyle: () => undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-dictation", () => ({
|
||||||
|
useDictation: () => ({
|
||||||
|
isRecording: false,
|
||||||
|
isProcessing: false,
|
||||||
|
partialTranscript: "",
|
||||||
|
volume: 0,
|
||||||
|
duration: 0,
|
||||||
|
error: null,
|
||||||
|
status: "idle",
|
||||||
|
startDictation: vi.fn(),
|
||||||
|
cancelDictation: vi.fn(),
|
||||||
|
confirmDictation: vi.fn(),
|
||||||
|
retryFailedDictation: vi.fn(),
|
||||||
|
discardFailedDictation: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/utils/server-info-capabilities", () => ({
|
||||||
|
resolveVoiceUnavailableMessage: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/shortcut", () => ({
|
||||||
|
Shortcut: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/tooltip", () => ({
|
||||||
|
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
TooltipTrigger: ({
|
||||||
|
asChild,
|
||||||
|
children,
|
||||||
|
onPress,
|
||||||
|
accessibilityLabel,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
asChild?: boolean;
|
||||||
|
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
|
||||||
|
onPress?: () => void;
|
||||||
|
accessibilityLabel?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) =>
|
||||||
|
asChild ? (
|
||||||
|
<>{children}</>
|
||||||
|
) : (
|
||||||
|
<button type="button" aria-label={accessibilityLabel} disabled={disabled} onClick={onPress}>
|
||||||
|
{typeof children === "function" ? children({ hovered: false }) : children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/dropdown-menu", () => {
|
||||||
|
const DropdownContext = React.createContext<{
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
DropdownMenu: ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
return (
|
||||||
|
<DropdownContext.Provider value={{ open, setOpen }}>{children}</DropdownContext.Provider>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
DropdownMenuTrigger: ({
|
||||||
|
children,
|
||||||
|
testID,
|
||||||
|
accessibilityLabel,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
children:
|
||||||
|
| React.ReactNode
|
||||||
|
| ((state: { hovered: boolean; pressed: boolean; open: boolean }) => React.ReactNode);
|
||||||
|
testID?: string;
|
||||||
|
accessibilityLabel?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) => {
|
||||||
|
const menu = React.useContext(DropdownContext);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={testID}
|
||||||
|
aria-label={accessibilityLabel}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => menu?.setOpen(true)}
|
||||||
|
>
|
||||||
|
{typeof children === "function"
|
||||||
|
? children({ hovered: false, pressed: false, open: menu?.open ?? false })
|
||||||
|
: children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => {
|
||||||
|
const menu = React.useContext(DropdownContext);
|
||||||
|
return menu?.open ? <div data-testid={testID}>{children}</div> : null;
|
||||||
|
},
|
||||||
|
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("@/components/ui/combobox", () => ({
|
||||||
|
Combobox: ({
|
||||||
|
open,
|
||||||
|
options,
|
||||||
|
renderOption,
|
||||||
|
anchorRef,
|
||||||
|
}: {
|
||||||
|
open?: boolean;
|
||||||
|
options: Array<{ id: string; label: string; description?: string }>;
|
||||||
|
renderOption?: (input: {
|
||||||
|
option: { id: string; label: string; description?: string };
|
||||||
|
selected: boolean;
|
||||||
|
active: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
}) => React.ReactElement;
|
||||||
|
anchorRef: React.RefObject<unknown>;
|
||||||
|
}) =>
|
||||||
|
open ? (
|
||||||
|
<div
|
||||||
|
data-testid="composer-github-combobox"
|
||||||
|
data-anchor={anchorRef.current ? "attached" : "missing"}
|
||||||
|
>
|
||||||
|
{options.map((option) =>
|
||||||
|
renderOption ? (
|
||||||
|
renderOption({ option, selected: false, active: false, onPress: vi.fn() })
|
||||||
|
) : (
|
||||||
|
<button type="button" key={option.id}>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
ComboboxItem: ({
|
||||||
|
label,
|
||||||
|
selected,
|
||||||
|
onPress,
|
||||||
|
testID,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
selected?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
testID?: string;
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={testID}
|
||||||
|
data-selected={selected ? "true" : "false"}
|
||||||
|
onClick={onPress}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./dictation-controls", () => ({
|
||||||
|
DictationOverlay: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./realtime-voice-overlay", () => ({
|
||||||
|
RealtimeVoiceOverlay: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLElement | null = null;
|
||||||
|
let queryClient: QueryClient | null = null;
|
||||||
|
let latestAttachments: ComposerAttachment[] = [];
|
||||||
|
|
||||||
|
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);
|
||||||
|
vi.stubGlobal("Blob", dom.window.Blob);
|
||||||
|
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
latestAttachments = [];
|
||||||
|
mockClient.searchGitHub.mockClear();
|
||||||
|
mockClient.sendAgentMessage.mockClear();
|
||||||
|
mockClient.cancelAgent.mockClear();
|
||||||
|
pickImagesMock.mockReset();
|
||||||
|
persistAttachmentFromBlobMock.mockClear();
|
||||||
|
deleteAttachmentsMock.mockClear();
|
||||||
|
encodeImagesMock.mockClear();
|
||||||
|
openExternalUrlMock.mockClear();
|
||||||
|
setAgentStreamTailMock.mockClear();
|
||||||
|
setAgentStreamHeadMock.mockClear();
|
||||||
|
setQueuedMessagesMock.mockClear();
|
||||||
|
mockSessionState.sessions.server.agentStreamHead = new Map();
|
||||||
|
mockSessionState.sessions.server.agentStreamTail = new Map();
|
||||||
|
mockSessionState.sessions.server.queuedMessages = new Map();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
queryClient?.clear();
|
||||||
|
root = null;
|
||||||
|
container = null;
|
||||||
|
queryClient = null;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function imageAttachment(id: string): AttachmentMetadata {
|
||||||
|
return {
|
||||||
|
...imageMetadata,
|
||||||
|
id,
|
||||||
|
storageKey: id,
|
||||||
|
fileName: `${id}.png`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComposerHarness({
|
||||||
|
initialText = "",
|
||||||
|
initialAttachments = [],
|
||||||
|
}: {
|
||||||
|
initialText?: string;
|
||||||
|
initialAttachments?: ComposerAttachment[];
|
||||||
|
}) {
|
||||||
|
const [text, setText] = useState(initialText);
|
||||||
|
const [attachments, setAttachments] = useState(initialAttachments);
|
||||||
|
latestAttachments = attachments;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient!}>
|
||||||
|
<Composer
|
||||||
|
agentId="agent"
|
||||||
|
serverId="server"
|
||||||
|
isPaneFocused
|
||||||
|
value={text}
|
||||||
|
onChangeText={setText}
|
||||||
|
attachments={attachments}
|
||||||
|
onChangeAttachments={(updater) => {
|
||||||
|
setAttachments((current) => {
|
||||||
|
const next = typeof updater === "function" ? updater(current) : updater;
|
||||||
|
latestAttachments = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
cwd="/repo"
|
||||||
|
clearDraft={vi.fn()}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComposer(
|
||||||
|
input: { initialText?: string; initialAttachments?: ComposerAttachment[] } = {},
|
||||||
|
) {
|
||||||
|
act(() => {
|
||||||
|
root?.render(<ComposerHarness {...input} />);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function click(element: Element) {
|
||||||
|
act(() => {
|
||||||
|
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushAsyncWork() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findByTestId(testID: string): Promise<HTMLElement> {
|
||||||
|
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||||
|
await flushAsyncWork();
|
||||||
|
const element = queryByTestId(testID);
|
||||||
|
if (element) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Missing element with testID ${testID}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryByTestId(testID: string): HTMLElement | null {
|
||||||
|
return document.querySelector(`[data-testid="${testID}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryAllAttachmentMenuItems(): NodeListOf<HTMLElement> {
|
||||||
|
return document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Composer attachments", () => {
|
||||||
|
it("opens a Plus menu with image and GitHub attachment actions", () => {
|
||||||
|
renderComposer();
|
||||||
|
|
||||||
|
click(queryByTestId("message-input-attach-button")!);
|
||||||
|
|
||||||
|
expect(queryAllAttachmentMenuItems()).toHaveLength(2);
|
||||||
|
expect(queryByTestId("message-input-attachment-menu-item-image")?.textContent).toBe(
|
||||||
|
"Add image",
|
||||||
|
);
|
||||||
|
expect(queryByTestId("message-input-attachment-menu-item-github")?.textContent).toBe(
|
||||||
|
"Add issue or PR",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a picked image as a unified composer attachment and renders a pill", async () => {
|
||||||
|
pickImagesMock.mockResolvedValue([
|
||||||
|
{
|
||||||
|
source: { kind: "blob", blob: new Blob(["image"]) },
|
||||||
|
mimeType: "image/png",
|
||||||
|
fileName: "img-1.png",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
renderComposer();
|
||||||
|
|
||||||
|
click(queryByTestId("message-input-attach-button")!);
|
||||||
|
click(queryByTestId("message-input-attachment-menu-item-image")!);
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(persistAttachmentFromBlobMock).toHaveBeenCalledWith({
|
||||||
|
blob: expect.any(Blob),
|
||||||
|
mimeType: "image/png",
|
||||||
|
fileName: "img-1.png",
|
||||||
|
});
|
||||||
|
expect(latestAttachments).toEqual([{ kind: "image", metadata: imageMetadata }]);
|
||||||
|
expect(queryByTestId("composer-image-attachment-pill")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the GitHub combobox from the Plus menu anchored at the Plus button", async () => {
|
||||||
|
renderComposer();
|
||||||
|
|
||||||
|
click(queryByTestId("message-input-attach-button")!);
|
||||||
|
click(queryByTestId("message-input-attachment-menu-item-github")!);
|
||||||
|
|
||||||
|
const combobox = await findByTestId("composer-github-combobox");
|
||||||
|
expect(combobox.dataset.anchor).toBe("attached");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the GitHub combobox after selecting an item", async () => {
|
||||||
|
renderComposer();
|
||||||
|
|
||||||
|
click(queryByTestId("message-input-attach-button")!);
|
||||||
|
click(queryByTestId("message-input-attachment-menu-item-github")!);
|
||||||
|
const issueOption = await findByTestId("composer-github-option-issue:101");
|
||||||
|
|
||||||
|
click(issueOption);
|
||||||
|
|
||||||
|
expect(latestAttachments).toEqual([{ kind: "github_issue", item: issueItem }]);
|
||||||
|
expect(queryByTestId("composer-github-combobox")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles GitHub search items into and out of unified composer attachments", async () => {
|
||||||
|
renderComposer();
|
||||||
|
|
||||||
|
click(queryByTestId("message-input-attach-button")!);
|
||||||
|
click(queryByTestId("message-input-attachment-menu-item-github")!);
|
||||||
|
const issueOption = await findByTestId("composer-github-option-issue:101");
|
||||||
|
|
||||||
|
click(issueOption);
|
||||||
|
expect(latestAttachments).toEqual([{ kind: "github_issue", item: issueItem }]);
|
||||||
|
expect(queryByTestId("composer-github-attachment-pill")).not.toBeNull();
|
||||||
|
expect(queryByTestId("composer-github-combobox")).toBeNull();
|
||||||
|
|
||||||
|
click(queryByTestId("message-input-attach-button")!);
|
||||||
|
click(queryByTestId("message-input-attachment-menu-item-github")!);
|
||||||
|
const selectedIssueOption = await findByTestId("composer-github-option-issue:101");
|
||||||
|
click(selectedIssueOption);
|
||||||
|
expect(latestAttachments).toEqual([]);
|
||||||
|
expect(queryByTestId("composer-github-combobox")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits mixed composer attachments as the expected wire images and attachments", async () => {
|
||||||
|
const image = imageAttachment("img-2");
|
||||||
|
renderComposer({
|
||||||
|
initialText: "send attachments",
|
||||||
|
initialAttachments: [
|
||||||
|
{ kind: "image", metadata: image },
|
||||||
|
{ kind: "github_pr", item: prItem },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
click(document.querySelector('[aria-label="Send message"]')!);
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(mockClient.sendAgentMessage).toHaveBeenCalledWith(
|
||||||
|
"agent",
|
||||||
|
"send attachments",
|
||||||
|
expect.objectContaining({
|
||||||
|
images: [image],
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: "github_pr",
|
||||||
|
mimeType: "application/github-pr",
|
||||||
|
number: 202,
|
||||||
|
title: "Refactor composer attachments",
|
||||||
|
url: "https://github.com/acme/paseo/pull/202",
|
||||||
|
body: "PR body",
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "composer-attachments",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits empty wire arrays when there are no composer attachments", async () => {
|
||||||
|
renderComposer({ initialText: "plain message" });
|
||||||
|
|
||||||
|
click(document.querySelector('[aria-label="Send message"]')!);
|
||||||
|
await flushAsyncWork();
|
||||||
|
|
||||||
|
expect(mockClient.sendAgentMessage).toHaveBeenCalledWith(
|
||||||
|
"agent",
|
||||||
|
"plain message",
|
||||||
|
expect.objectContaining({
|
||||||
|
images: [],
|
||||||
|
attachments: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the image attachment when its pill X button is pressed", () => {
|
||||||
|
const image = imageAttachment("img-remove");
|
||||||
|
renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] });
|
||||||
|
|
||||||
|
const removeButton = document.querySelector('[aria-label="Remove image attachment"]');
|
||||||
|
expect(removeButton).not.toBeNull();
|
||||||
|
click(removeButton!);
|
||||||
|
|
||||||
|
expect(latestAttachments).toEqual([]);
|
||||||
|
expect(deleteAttachmentsMock).toHaveBeenCalledWith([image]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a GitHub attachment when its pill X button is pressed", () => {
|
||||||
|
renderComposer({ initialAttachments: [{ kind: "github_issue", item: issueItem }] });
|
||||||
|
|
||||||
|
const removeButton = document.querySelector(`[aria-label="Remove issue #${issueItem.number}"]`);
|
||||||
|
expect(removeButton).not.toBeNull();
|
||||||
|
click(removeButton!);
|
||||||
|
|
||||||
|
expect(latestAttachments).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the GitHub issue URL when the pill body is pressed", () => {
|
||||||
|
renderComposer({ initialAttachments: [{ kind: "github_issue", item: issueItem }] });
|
||||||
|
|
||||||
|
click(queryByTestId("composer-github-attachment-pill")!);
|
||||||
|
|
||||||
|
expect(openExternalUrlMock).toHaveBeenCalledWith(issueItem.url);
|
||||||
|
expect(latestAttachments).toEqual([{ kind: "github_issue", item: issueItem }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the GitHub PR URL when the pill body is pressed", () => {
|
||||||
|
renderComposer({ initialAttachments: [{ kind: "github_pr", item: prItem }] });
|
||||||
|
|
||||||
|
click(queryByTestId("composer-github-attachment-pill")!);
|
||||||
|
|
||||||
|
expect(openExternalUrlMock).toHaveBeenCalledWith(prItem.url);
|
||||||
|
expect(latestAttachments).toEqual([{ kind: "github_pr", item: prItem }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the image lightbox when the image pill body is pressed", () => {
|
||||||
|
const image = imageAttachment("img-body");
|
||||||
|
renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] });
|
||||||
|
|
||||||
|
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
|
||||||
|
|
||||||
|
click(queryByTestId("composer-image-attachment-pill")!);
|
||||||
|
|
||||||
|
expect(queryByTestId("attachment-lightbox-image")).not.toBeNull();
|
||||||
|
expect(openExternalUrlMock).not.toHaveBeenCalled();
|
||||||
|
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the image lightbox when its close button is pressed", () => {
|
||||||
|
const image = imageAttachment("img-close");
|
||||||
|
renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] });
|
||||||
|
|
||||||
|
click(queryByTestId("composer-image-attachment-pill")!);
|
||||||
|
expect(queryByTestId("attachment-lightbox-image")).not.toBeNull();
|
||||||
|
|
||||||
|
const closeButton = document.querySelector(
|
||||||
|
'[aria-label="Close image"][data-testid="attachment-lightbox-close"]',
|
||||||
|
);
|
||||||
|
expect(closeButton).not.toBeNull();
|
||||||
|
click(closeButton!);
|
||||||
|
|
||||||
|
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits mixed composer attachments only at the submit wire boundary", () => {
|
||||||
|
const image = imageAttachment("img-3");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
splitComposerAttachmentsForSubmit([
|
||||||
|
{ kind: "image", metadata: image },
|
||||||
|
{ kind: "github_issue", item: issueItem },
|
||||||
|
{ kind: "github_pr", item: prItem },
|
||||||
|
]),
|
||||||
|
).toEqual({
|
||||||
|
images: [image],
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: "github_issue",
|
||||||
|
mimeType: "application/github-issue",
|
||||||
|
number: 101,
|
||||||
|
title: "Fix composer attachments",
|
||||||
|
url: "https://github.com/acme/paseo/issues/101",
|
||||||
|
body: "Issue body",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "github_pr",
|
||||||
|
mimeType: "application/github-pr",
|
||||||
|
number: 202,
|
||||||
|
title: "Refactor composer attachments",
|
||||||
|
url: "https://github.com/acme/paseo/pull/202",
|
||||||
|
body: "PR body",
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "composer-attachments",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+392
-97
@@ -1,11 +1,21 @@
|
|||||||
import { View, Pressable, Text, ActivityIndicator } from "react-native";
|
import { View, Pressable, Text, ActivityIndicator, Image } from "react-native";
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
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 { useShallow } from "zustand/shallow";
|
import { useShallow } from "zustand/shallow";
|
||||||
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
|
import {
|
||||||
|
ArrowUp,
|
||||||
|
Square,
|
||||||
|
Pencil,
|
||||||
|
AudioLines,
|
||||||
|
CircleDot,
|
||||||
|
GitPullRequest,
|
||||||
|
Github,
|
||||||
|
Paperclip,
|
||||||
|
} from "lucide-react-native";
|
||||||
import Animated from "react-native-reanimated";
|
import Animated from "react-native-reanimated";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +31,7 @@ import {
|
|||||||
type MessagePayload,
|
type MessagePayload,
|
||||||
type ImageAttachment,
|
type ImageAttachment,
|
||||||
type MessageInputRef,
|
type MessageInputRef,
|
||||||
|
type AttachmentMenuItem,
|
||||||
} from "./message-input";
|
} from "./message-input";
|
||||||
import { Theme } from "@/styles/theme";
|
import { Theme } from "@/styles/theme";
|
||||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||||
@@ -43,7 +54,7 @@ import {
|
|||||||
persistAttachmentFromBlob,
|
persistAttachmentFromBlob,
|
||||||
persistAttachmentFromFileUri,
|
persistAttachmentFromFileUri,
|
||||||
} from "@/attachments/service";
|
} from "@/attachments/service";
|
||||||
import { resolveStatusControlMode } from "@/components/agent-input-area.status-controls";
|
import { resolveStatusControlMode } from "@/components/composer.status-controls";
|
||||||
import { markScrollInvestigationRender } from "@/utils/scroll-jank-investigation";
|
import { markScrollInvestigationRender } from "@/utils/scroll-jank-investigation";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
@@ -51,28 +62,53 @@ import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispat
|
|||||||
import { submitAgentInput } from "@/components/agent-input-submit";
|
import { submitAgentInput } from "@/components/agent-input-submit";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import { isWeb, isNative } from "@/constants/platform";
|
import { isWeb, isNative } from "@/constants/platform";
|
||||||
|
import type { GitHubSearchItem } from "@server/shared/messages";
|
||||||
|
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
||||||
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
|
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
||||||
|
import { AttachmentPill } from "@/components/attachment-pill";
|
||||||
|
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
||||||
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
|
|
||||||
type QueuedMessage = {
|
type QueuedMessage = {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
images?: ImageAttachment[];
|
attachments: ComposerAttachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageAttachment[]);
|
type AttachmentListUpdater =
|
||||||
|
| ComposerAttachment[]
|
||||||
|
| ((prev: ComposerAttachment[]) => ComposerAttachment[]);
|
||||||
|
|
||||||
interface AgentInputAreaProps {
|
function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) {
|
||||||
|
const uri = useAttachmentPreviewUrl(image);
|
||||||
|
if (!uri) {
|
||||||
|
return <View style={styles.imageThumbnailPlaceholder} />;
|
||||||
|
}
|
||||||
|
return <Image source={{ uri }} style={styles.imageThumbnail} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComposerProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
isInputActive: boolean;
|
isPaneFocused: boolean;
|
||||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
||||||
|
/** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */
|
||||||
|
hasExternalContent?: boolean;
|
||||||
|
/** When true, the composer can submit even with no text or attachments. */
|
||||||
|
allowEmptySubmit?: boolean;
|
||||||
|
/** Optional accessibility label for the primary submit button. */
|
||||||
|
submitButtonAccessibilityLabel?: string;
|
||||||
/** Externally controlled loading state. When true, disables the submit button. */
|
/** Externally controlled loading state. When true, disables the submit button. */
|
||||||
isSubmitLoading?: boolean;
|
isSubmitLoading?: boolean;
|
||||||
/** When true, blurs the input immediately when submitting. */
|
/** When true, blurs the input immediately when submitting. */
|
||||||
blurOnSubmit?: boolean;
|
blurOnSubmit?: boolean;
|
||||||
value: string;
|
value: string;
|
||||||
onChangeText: (text: string) => void;
|
onChangeText: (text: string) => void;
|
||||||
images: ImageAttachment[];
|
attachments: ComposerAttachment[];
|
||||||
onChangeImages: (updater: ImageListUpdater) => void;
|
onChangeAttachments: (updater: AttachmentListUpdater) => void;
|
||||||
|
cwd: string;
|
||||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||||
/** When true, auto-focuses the text input on web. */
|
/** When true, auto-focuses the text input on web. */
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
@@ -89,23 +125,29 @@ interface AgentInputAreaProps {
|
|||||||
onAttentionPromptSend?: () => void;
|
onAttentionPromptSend?: () => void;
|
||||||
/** Controlled status controls rendered in input area (draft flows). */
|
/** Controlled status controls rendered in input area (draft flows). */
|
||||||
statusControls?: DraftAgentStatusBarProps;
|
statusControls?: DraftAgentStatusBarProps;
|
||||||
|
/** Extra styles merged onto the message input wrapper (e.g. elevated background). */
|
||||||
|
inputWrapperStyle?: import("react-native").ViewStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
||||||
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
|
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
|
||||||
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
||||||
|
|
||||||
export function AgentInputArea({
|
export function Composer({
|
||||||
agentId,
|
agentId,
|
||||||
serverId,
|
serverId,
|
||||||
isInputActive,
|
isPaneFocused,
|
||||||
onSubmitMessage,
|
onSubmitMessage,
|
||||||
|
hasExternalContent = false,
|
||||||
|
allowEmptySubmit = false,
|
||||||
|
submitButtonAccessibilityLabel,
|
||||||
isSubmitLoading = false,
|
isSubmitLoading = false,
|
||||||
blurOnSubmit = false,
|
blurOnSubmit = false,
|
||||||
value,
|
value,
|
||||||
onChangeText,
|
onChangeText,
|
||||||
images,
|
attachments,
|
||||||
onChangeImages,
|
onChangeAttachments,
|
||||||
|
cwd,
|
||||||
clearDraft,
|
clearDraft,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
onAddImages,
|
onAddImages,
|
||||||
@@ -116,8 +158,9 @@ export function AgentInputArea({
|
|||||||
onAttentionInputFocus,
|
onAttentionInputFocus,
|
||||||
onAttentionPromptSend,
|
onAttentionPromptSend,
|
||||||
statusControls,
|
statusControls,
|
||||||
}: AgentInputAreaProps) {
|
inputWrapperStyle,
|
||||||
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`);
|
}: ComposerProps) {
|
||||||
|
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
|
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
@@ -163,13 +206,17 @@ export function AgentInputArea({
|
|||||||
: MOBILE_MESSAGE_PLACEHOLDER;
|
: MOBILE_MESSAGE_PLACEHOLDER;
|
||||||
const userInput = value;
|
const userInput = value;
|
||||||
const setUserInput = onChangeText;
|
const setUserInput = onChangeText;
|
||||||
const selectedImages = images;
|
const selectedAttachments = attachments;
|
||||||
const setSelectedImages = onChangeImages;
|
const setSelectedAttachments = onChangeAttachments;
|
||||||
const [cursorIndex, setCursorIndex] = useState(0);
|
const [cursorIndex, setCursorIndex] = useState(0);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
||||||
const [sendError, setSendError] = useState<string | null>(null);
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false);
|
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false);
|
||||||
|
const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false);
|
||||||
|
const [githubSearchQuery, setGithubSearchQuery] = useState("");
|
||||||
|
const [lightboxMetadata, setLightboxMetadata] = useState<AttachmentMetadata | null>(null);
|
||||||
|
const attachButtonRef = useRef<View | null>(null);
|
||||||
const messageInputRef = useRef<MessageInputRef>(null);
|
const messageInputRef = useRef<MessageInputRef>(null);
|
||||||
const keyboardHandlerIdRef = useRef(
|
const keyboardHandlerIdRef = useRef(
|
||||||
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
|
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
|
||||||
@@ -201,16 +248,19 @@ export function AgentInputArea({
|
|||||||
const { pickImages } = useImageAttachmentPicker();
|
const { pickImages } = useImageAttachmentPicker();
|
||||||
const agentIdRef = useRef(agentId);
|
const agentIdRef = useRef(agentId);
|
||||||
const sendAgentMessageRef = useRef<
|
const sendAgentMessageRef = useRef<
|
||||||
((agentId: string, text: string, images?: ImageAttachment[]) => Promise<void>) | null
|
((agentId: string, text: string, attachments: ComposerAttachment[]) => Promise<void>) | null
|
||||||
>(null);
|
>(null);
|
||||||
const onSubmitMessageRef = useRef(onSubmitMessage);
|
const onSubmitMessageRef = useRef(onSubmitMessage);
|
||||||
|
|
||||||
// Expose addImages function to parent for drag-and-drop support
|
// Expose addImages function to parent for drag-and-drop support
|
||||||
const addImages = useCallback(
|
const addImages = useCallback(
|
||||||
(images: ImageAttachment[]) => {
|
(images: ImageAttachment[]) => {
|
||||||
setSelectedImages((prev) => [...prev, ...images]);
|
setSelectedAttachments((prev) => [
|
||||||
|
...prev,
|
||||||
|
...images.map((metadata) => ({ kind: "image" as const, metadata })),
|
||||||
|
]);
|
||||||
},
|
},
|
||||||
[setSelectedImages],
|
[setSelectedAttachments],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -233,18 +283,18 @@ export function AgentInputArea({
|
|||||||
}, [focusInput, onFocusInput]);
|
}, [focusInput, onFocusInput]);
|
||||||
|
|
||||||
const submitMessage = useCallback(
|
const submitMessage = useCallback(
|
||||||
async (text: string, images?: ImageAttachment[]) => {
|
async (text: string, attachments: ComposerAttachment[]) => {
|
||||||
onMessageSent?.();
|
onMessageSent?.();
|
||||||
if (onSubmitMessageRef.current) {
|
if (onSubmitMessageRef.current) {
|
||||||
await onSubmitMessageRef.current({ text, images });
|
await onSubmitMessageRef.current({ text, attachments, cwd });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!sendAgentMessageRef.current) {
|
if (!sendAgentMessageRef.current) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
}
|
}
|
||||||
await sendAgentMessageRef.current(agentIdRef.current, text, images);
|
await sendAgentMessageRef.current(agentIdRef.current, text, attachments);
|
||||||
},
|
},
|
||||||
[onMessageSent],
|
[cwd, onMessageSent],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -255,19 +305,20 @@ export function AgentInputArea({
|
|||||||
sendAgentMessageRef.current = async (
|
sendAgentMessageRef.current = async (
|
||||||
agentId: string,
|
agentId: string,
|
||||||
text: string,
|
text: string,
|
||||||
images?: ImageAttachment[],
|
attachments: ComposerAttachment[],
|
||||||
) => {
|
) => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
|
||||||
const clientMessageId = generateMessageId();
|
const clientMessageId = generateMessageId();
|
||||||
const userMessage: StreamItem = {
|
const userMessage: StreamItem = {
|
||||||
kind: "user_message",
|
kind: "user_message",
|
||||||
id: clientMessageId,
|
id: clientMessageId,
|
||||||
text,
|
text,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
...(images && images.length > 0 ? { images } : {}),
|
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Append to head if streaming (keeps the user message with the current
|
// Append to head if streaming (keeps the user message with the current
|
||||||
@@ -291,10 +342,11 @@ export function AgentInputArea({
|
|||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const imagesData = await encodeImages(images);
|
const imagesData = await encodeImages(wirePayload.images);
|
||||||
await client.sendAgentMessage(agentId, text, {
|
await client.sendAgentMessage(agentId, text, {
|
||||||
messageId: clientMessageId,
|
messageId: clientMessageId,
|
||||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
images: imagesData ?? [],
|
||||||
|
attachments: wirePayload.attachments,
|
||||||
});
|
});
|
||||||
onAttentionPromptSend?.();
|
onAttentionPromptSend?.();
|
||||||
};
|
};
|
||||||
@@ -318,14 +370,14 @@ export function AgentInputArea({
|
|||||||
[agentId, serverId, setQueuedMessages],
|
[agentId, serverId, setQueuedMessages],
|
||||||
);
|
);
|
||||||
|
|
||||||
function queueMessage(message: string, imageAttachments?: ImageAttachment[]) {
|
function queueMessage(message: string, attachments: ComposerAttachment[]) {
|
||||||
const trimmedMessage = message.trim();
|
const trimmedMessage = message.trim();
|
||||||
if (!trimmedMessage && !imageAttachments?.length) return;
|
if (!trimmedMessage && attachments.length === 0) return;
|
||||||
|
|
||||||
const newItem = {
|
const newItem = {
|
||||||
id: generateMessageId(),
|
id: generateMessageId(),
|
||||||
text: trimmedMessage,
|
text: trimmedMessage,
|
||||||
images: imageAttachments,
|
attachments,
|
||||||
};
|
};
|
||||||
|
|
||||||
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
||||||
@@ -335,32 +387,34 @@ export function AgentInputArea({
|
|||||||
});
|
});
|
||||||
|
|
||||||
setUserInput("");
|
setUserInput("");
|
||||||
setSelectedImages([]);
|
setSelectedAttachments([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessageWithContent(
|
async function sendMessageWithContent(
|
||||||
message: string,
|
message: string,
|
||||||
imageAttachments?: ImageAttachment[],
|
attachments: ComposerAttachment[],
|
||||||
forceSend?: boolean,
|
forceSend?: boolean,
|
||||||
) {
|
) {
|
||||||
await submitAgentInput({
|
await submitAgentInput({
|
||||||
message,
|
message,
|
||||||
imageAttachments,
|
attachments,
|
||||||
|
hasExternalContent,
|
||||||
|
allowEmptySubmit,
|
||||||
forceSend,
|
forceSend,
|
||||||
isAgentRunning: agentState.status === "running",
|
isAgentRunning: agentState.status === "running",
|
||||||
// Parent-managed submits are still valid submit paths even when the
|
// Parent-managed submits are still valid submit paths even when the
|
||||||
// transport is disconnected, because the parent decides the failure mode.
|
// transport is disconnected, because the parent decides the failure mode.
|
||||||
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
|
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
|
||||||
queueMessage: ({ message, imageAttachments }) => {
|
queueMessage: ({ message, attachments }) => {
|
||||||
queueMessage(message, imageAttachments);
|
queueMessage(message, attachments);
|
||||||
},
|
},
|
||||||
submitMessage: async ({ message, imageAttachments }) => {
|
submitMessage: async ({ message, attachments }) => {
|
||||||
await submitMessage(message, imageAttachments);
|
await submitMessage(message, attachments);
|
||||||
},
|
},
|
||||||
clearDraft,
|
clearDraft,
|
||||||
setUserInput,
|
setUserInput,
|
||||||
setSelectedImages: (images) => {
|
setAttachments: (nextAttachments) => {
|
||||||
setSelectedImages(images);
|
setSelectedAttachments(nextAttachments);
|
||||||
},
|
},
|
||||||
setSendError,
|
setSendError,
|
||||||
setIsProcessing,
|
setIsProcessing,
|
||||||
@@ -374,10 +428,10 @@ export function AgentInputArea({
|
|||||||
if (blurOnSubmit) {
|
if (blurOnSubmit) {
|
||||||
messageInputRef.current?.blur();
|
messageInputRef.current?.blur();
|
||||||
}
|
}
|
||||||
void sendMessageWithContent(payload.text, payload.images, payload.forceSend);
|
void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePickImage() {
|
const handlePickImage = useCallback(async () => {
|
||||||
const result = await pickImages();
|
const result = await pickImages();
|
||||||
if (!result?.length) {
|
if (!result?.length) {
|
||||||
return;
|
return;
|
||||||
@@ -400,19 +454,27 @@ export function AgentInputArea({
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
setSelectedImages((prev) => [...prev, ...newImages]);
|
addImages(newImages);
|
||||||
}
|
}, [addImages, pickImages]);
|
||||||
|
|
||||||
function handleRemoveImage(index: number) {
|
function handleRemoveAttachment(index: number) {
|
||||||
setSelectedImages((prev) => {
|
setSelectedAttachments((prev) => {
|
||||||
const removed = prev[index];
|
const removed = prev[index];
|
||||||
if (removed) {
|
if (removed?.kind === "image") {
|
||||||
void deleteAttachments([removed]);
|
void deleteAttachments([removed.metadata]);
|
||||||
}
|
}
|
||||||
return prev.filter((_, i) => i !== index);
|
return prev.filter((_, i) => i !== index);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleOpenAttachment(attachment: ComposerAttachment) {
|
||||||
|
if (attachment.kind === "image") {
|
||||||
|
setLightboxMetadata(attachment.metadata);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void openExternalUrl(attachment.item.url);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAgentRunning || !isConnected) {
|
if (!isAgentRunning || !isConnected) {
|
||||||
setIsCancellingAgent(false);
|
setIsCancellingAgent(false);
|
||||||
@@ -421,7 +483,7 @@ export function AgentInputArea({
|
|||||||
|
|
||||||
const handleKeyboardAction = useCallback(
|
const handleKeyboardAction = useCallback(
|
||||||
(action: KeyboardActionDefinition): boolean => {
|
(action: KeyboardActionDefinition): boolean => {
|
||||||
if (!isInputActive) {
|
if (!isPaneFocused) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,7 +523,7 @@ export function AgentInputArea({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isInputActive],
|
[isPaneFocused],
|
||||||
);
|
);
|
||||||
|
|
||||||
useKeyboardActionHandler({
|
useKeyboardActionHandler({
|
||||||
@@ -475,9 +537,9 @@ export function AgentInputArea({
|
|||||||
"message-input.voice-toggle",
|
"message-input.voice-toggle",
|
||||||
"message-input.voice-mute-toggle",
|
"message-input.voice-mute-toggle",
|
||||||
],
|
],
|
||||||
enabled: isInputActive,
|
enabled: isPaneFocused,
|
||||||
priority: isMessageInputFocused ? 200 : 100,
|
priority: isMessageInputFocused ? 200 : 100,
|
||||||
isActive: () => isInputActive,
|
isActive: () => isPaneFocused,
|
||||||
handle: handleKeyboardAction,
|
handle: handleKeyboardAction,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -510,7 +572,7 @@ export function AgentInputArea({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void voice.startVoice(serverId, agentId).catch((error) => {
|
void voice.startVoice(serverId, agentId).catch((error) => {
|
||||||
console.error("[AgentInputArea] Failed to start voice mode", error);
|
console.error("[Composer] Failed to start voice mode", error);
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : typeof error === "string" ? error : null;
|
error instanceof Error ? error.message : typeof error === "string" ? error : null;
|
||||||
if (message && message.trim().length > 0) {
|
if (message && message.trim().length > 0) {
|
||||||
@@ -525,7 +587,7 @@ export function AgentInputArea({
|
|||||||
|
|
||||||
updateQueue((current) => current.filter((q) => q.id !== id));
|
updateQueue((current) => current.filter((q) => q.id !== id));
|
||||||
setUserInput(item.text);
|
setUserInput(item.text);
|
||||||
setSelectedImages(item.images ?? []);
|
setSelectedAttachments(item.attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSendQueuedNow(id: string) {
|
async function handleSendQueuedNow(id: string) {
|
||||||
@@ -537,7 +599,7 @@ export function AgentInputArea({
|
|||||||
|
|
||||||
// Reuse the regular send path; server-side send atomically interrupts any active run.
|
// Reuse the regular send path; server-side send atomically interrupts any active run.
|
||||||
try {
|
try {
|
||||||
await submitMessage(item.text, item.images);
|
await submitMessage(item.text, item.attachments);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
updateQueue((current) => [item, ...current]);
|
updateQueue((current) => [item, ...current]);
|
||||||
setSendError(error instanceof Error ? error.message : "Failed to send message");
|
setSendError(error instanceof Error ? error.message : "Failed to send message");
|
||||||
@@ -545,10 +607,10 @@ export function AgentInputArea({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleQueue = useCallback((payload: MessagePayload) => {
|
const handleQueue = useCallback((payload: MessagePayload) => {
|
||||||
queueMessage(payload.text, payload.images);
|
queueMessage(payload.text, payload.attachments);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const hasSendableContent = userInput.trim().length > 0 || selectedImages.length > 0;
|
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
|
||||||
|
|
||||||
// Handle keyboard navigation for command autocomplete and stop action.
|
// Handle keyboard navigation for command autocomplete and stop action.
|
||||||
const handleCommandKeyPress = useCallback(
|
const handleCommandKeyPress = useCallback(
|
||||||
@@ -607,40 +669,49 @@ export function AgentInputArea({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
const rightContent = (
|
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent;
|
||||||
<View style={styles.rightControls}>
|
const rightContent =
|
||||||
{!isVoiceModeForAgent && hasAgent ? (
|
showVoiceModeButton || cancelButton ? (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<View style={styles.rightControls}>
|
||||||
<TooltipTrigger
|
{showVoiceModeButton ? (
|
||||||
onPress={handleToggleRealtimeVoice}
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
disabled={!isConnected || voice?.isVoiceSwitching}
|
<TooltipTrigger
|
||||||
accessibilityLabel="Enable Voice mode"
|
onPress={handleToggleRealtimeVoice}
|
||||||
accessibilityRole="button"
|
disabled={!isConnected || voice?.isVoiceSwitching}
|
||||||
style={({ hovered }) => [
|
accessibilityLabel="Enable Voice mode"
|
||||||
styles.realtimeVoiceButton as any,
|
accessibilityRole="button"
|
||||||
(hovered ? styles.iconButtonHovered : undefined) as any,
|
style={({ hovered }) => [
|
||||||
(!isConnected || voice?.isVoiceSwitching ? styles.buttonDisabled : undefined) as any,
|
styles.realtimeVoiceButton as any,
|
||||||
]}
|
(hovered ? styles.iconButtonHovered : undefined) as any,
|
||||||
>
|
(!isConnected || voice?.isVoiceSwitching
|
||||||
{voice?.isVoiceSwitching ? (
|
? styles.buttonDisabled
|
||||||
<ActivityIndicator size="small" color="white" />
|
: undefined) as any,
|
||||||
) : (
|
]}
|
||||||
<AudioLines size={buttonIconSize} color={theme.colors.foreground} />
|
>
|
||||||
)}
|
{({ hovered }) =>
|
||||||
</TooltipTrigger>
|
voice?.isVoiceSwitching ? (
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
<ActivityIndicator size="small" color="white" />
|
||||||
<View style={styles.tooltipRow}>
|
) : (
|
||||||
<Text style={styles.tooltipText}>Voice mode</Text>
|
<AudioLines
|
||||||
{voiceToggleKeys ? (
|
size={buttonIconSize}
|
||||||
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||||
) : null}
|
/>
|
||||||
</View>
|
)
|
||||||
</TooltipContent>
|
}
|
||||||
</Tooltip>
|
</TooltipTrigger>
|
||||||
) : null}
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
{cancelButton}
|
<View style={styles.tooltipRow}>
|
||||||
</View>
|
<Text style={styles.tooltipText}>Voice mode</Text>
|
||||||
);
|
{voiceToggleKeys ? (
|
||||||
|
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
{cancelButton}
|
||||||
|
</View>
|
||||||
|
) : null;
|
||||||
|
|
||||||
const hasContextWindowMeter =
|
const hasContextWindowMeter =
|
||||||
typeof agentState.contextWindowMaxTokens === "number" &&
|
typeof agentState.contextWindowMaxTokens === "number" &&
|
||||||
@@ -659,6 +730,78 @@ export function AgentInputArea({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const githubSearchQueryTrimmed = githubSearchQuery.trim();
|
||||||
|
const githubSearchResultsQuery = useQuery({
|
||||||
|
queryKey: ["composer-github-search", serverId, cwd, githubSearchQueryTrimmed],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!client) {
|
||||||
|
throw new Error("Host is not connected");
|
||||||
|
}
|
||||||
|
return client.searchGitHub({
|
||||||
|
cwd,
|
||||||
|
query: githubSearchQueryTrimmed,
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: isConnected && !!client && cwd.trim().length > 0,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const githubSearchItems = githubSearchResultsQuery.data?.items ?? [];
|
||||||
|
const githubSearchOptions: ComboboxOption[] = useMemo(
|
||||||
|
() =>
|
||||||
|
githubSearchItems.map((item) => ({
|
||||||
|
id: `${item.kind}:${item.number}`,
|
||||||
|
label: `#${item.number} ${item.title}`,
|
||||||
|
description: githubSearchQueryTrimmed,
|
||||||
|
})),
|
||||||
|
[githubSearchItems, githubSearchQueryTrimmed],
|
||||||
|
);
|
||||||
|
|
||||||
|
const attachmentMenuItems = useMemo<AttachmentMenuItem[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "image",
|
||||||
|
label: "Add image",
|
||||||
|
icon: <Paperclip size={theme.iconSize.md} color={theme.colors.foregroundMuted} />,
|
||||||
|
onSelect: () => {
|
||||||
|
void handlePickImage();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "github",
|
||||||
|
label: "Add issue or PR",
|
||||||
|
icon: <Github size={theme.iconSize.md} color={theme.colors.foregroundMuted} />,
|
||||||
|
onSelect: () => {
|
||||||
|
setIsGithubPickerOpen(true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[handlePickImage, theme.colors.foregroundMuted, theme.iconSize.md],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleGithubItem = useCallback(
|
||||||
|
(item: GitHubSearchItem) => {
|
||||||
|
setSelectedAttachments((current) => {
|
||||||
|
const matches = (attachment: ComposerAttachment) =>
|
||||||
|
attachment.kind !== "image" &&
|
||||||
|
attachment.item.kind === item.kind &&
|
||||||
|
attachment.item.number === item.number;
|
||||||
|
|
||||||
|
if (current.some(matches)) {
|
||||||
|
return current.filter((attachment) => !matches(attachment));
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAttachment: ComposerAttachment =
|
||||||
|
item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
|
||||||
|
return [...current, nextAttachment];
|
||||||
|
});
|
||||||
|
setIsGithubPickerOpen(false);
|
||||||
|
setGithubSearchQuery("");
|
||||||
|
},
|
||||||
|
[setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
|
||||||
|
);
|
||||||
|
|
||||||
const leftContent =
|
const leftContent =
|
||||||
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
|
||||||
<DraftAgentStatusBar {...statusControls} />
|
<DraftAgentStatusBar {...statusControls} />
|
||||||
@@ -670,6 +813,7 @@ export function AgentInputArea({
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={[styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle]}
|
style={[styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle]}
|
||||||
>
|
>
|
||||||
|
<AttachmentLightbox metadata={lightboxMetadata} onClose={() => setLightboxMetadata(null)} />
|
||||||
{/* Input area */}
|
{/* Input area */}
|
||||||
<View style={styles.inputAreaContainer}>
|
<View style={styles.inputAreaContainer}>
|
||||||
<View style={styles.inputAreaContent}>
|
<View style={styles.inputAreaContent}>
|
||||||
@@ -718,25 +862,84 @@ export function AgentInputArea({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedAttachments.length > 0 ? (
|
||||||
|
<View style={styles.attachmentPreviewContainer} testID="composer-attachment-pills">
|
||||||
|
{selectedAttachments.map((attachment, index) => {
|
||||||
|
if (attachment.kind === "image") {
|
||||||
|
return (
|
||||||
|
<AttachmentPill
|
||||||
|
key={`${attachment.metadata.id}-${index}`}
|
||||||
|
testID="composer-image-attachment-pill"
|
||||||
|
onOpen={() => handleOpenAttachment(attachment)}
|
||||||
|
onRemove={() => handleRemoveAttachment(index)}
|
||||||
|
openAccessibilityLabel="Open image attachment"
|
||||||
|
removeAccessibilityLabel="Remove image attachment"
|
||||||
|
>
|
||||||
|
<ImageAttachmentThumbnail image={attachment.metadata} />
|
||||||
|
</AttachmentPill>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = attachment.item;
|
||||||
|
const kindLabel = item.kind === "pr" ? "PR" : "issue";
|
||||||
|
return (
|
||||||
|
<AttachmentPill
|
||||||
|
key={`${item.kind}:${item.number}`}
|
||||||
|
testID="composer-github-attachment-pill"
|
||||||
|
onOpen={() => handleOpenAttachment(attachment)}
|
||||||
|
onRemove={() => handleRemoveAttachment(index)}
|
||||||
|
openAccessibilityLabel={`Open ${kindLabel} #${item.number}`}
|
||||||
|
removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`}
|
||||||
|
>
|
||||||
|
<View style={styles.githubPillBody}>
|
||||||
|
<View style={styles.githubPillIcon}>
|
||||||
|
{item.kind === "pr" ? (
|
||||||
|
<GitPullRequest
|
||||||
|
size={theme.iconSize.sm}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CircleDot
|
||||||
|
size={theme.iconSize.sm}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.githubPillText} numberOfLines={1}>
|
||||||
|
#{item.number} {item.title}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</AttachmentPill>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||||
<MessageInput
|
<MessageInput
|
||||||
ref={messageInputRef}
|
ref={messageInputRef}
|
||||||
value={userInput}
|
value={userInput}
|
||||||
onChangeText={setUserInput}
|
onChangeText={setUserInput}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
hasExternalContent={hasExternalContent}
|
||||||
|
allowEmptySubmit={allowEmptySubmit}
|
||||||
|
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||||
isSubmitDisabled={isProcessing || isSubmitLoading}
|
isSubmitDisabled={isProcessing || isSubmitLoading}
|
||||||
isSubmitLoading={isProcessing || isSubmitLoading}
|
isSubmitLoading={isProcessing || isSubmitLoading}
|
||||||
images={selectedImages}
|
attachments={selectedAttachments}
|
||||||
onPickImages={handlePickImage}
|
cwd={cwd}
|
||||||
|
attachmentMenuItems={attachmentMenuItems}
|
||||||
|
onAttachButtonRef={(node) => {
|
||||||
|
attachButtonRef.current = node;
|
||||||
|
}}
|
||||||
onAddImages={addImages}
|
onAddImages={addImages}
|
||||||
onRemoveImage={handleRemoveImage}
|
|
||||||
client={client}
|
client={client}
|
||||||
isReadyForDictation={isDictationReady}
|
isReadyForDictation={isDictationReady}
|
||||||
placeholder={messagePlaceholder}
|
placeholder={messagePlaceholder}
|
||||||
autoFocus={autoFocus && isDesktopWebBreakpoint}
|
autoFocus={autoFocus && isDesktopWebBreakpoint}
|
||||||
autoFocusKey={`${serverId}:${agentId}`}
|
autoFocusKey={`${serverId}:${agentId}`}
|
||||||
disabled={isSubmitLoading}
|
disabled={isSubmitLoading}
|
||||||
isInputActive={isInputActive}
|
isPaneFocused={isPaneFocused}
|
||||||
leftContent={leftContent}
|
leftContent={leftContent}
|
||||||
beforeVoiceContent={beforeVoiceContent}
|
beforeVoiceContent={beforeVoiceContent}
|
||||||
rightContent={rightContent}
|
rightContent={rightContent}
|
||||||
@@ -757,6 +960,61 @@ export function AgentInputArea({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onHeightChange={onComposerHeightChange}
|
onHeightChange={onComposerHeightChange}
|
||||||
|
inputWrapperStyle={inputWrapperStyle}
|
||||||
|
/>
|
||||||
|
<Combobox
|
||||||
|
options={githubSearchOptions}
|
||||||
|
value=""
|
||||||
|
onSelect={() => {}}
|
||||||
|
keepOpenOnSelect
|
||||||
|
searchable
|
||||||
|
searchPlaceholder="Search issues and PRs..."
|
||||||
|
title="Attach issue or PR"
|
||||||
|
open={isGithubPickerOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setIsGithubPickerOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setGithubSearchQuery("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSearchQueryChange={setGithubSearchQuery}
|
||||||
|
desktopPlacement="top-start"
|
||||||
|
anchorRef={attachButtonRef}
|
||||||
|
emptyText={githubSearchResultsQuery.isFetching ? "Searching..." : "No results found."}
|
||||||
|
renderOption={({ option, active }) => {
|
||||||
|
const item = githubSearchItems.find((candidate) => {
|
||||||
|
return `${candidate.kind}:${candidate.number}` === option.id;
|
||||||
|
});
|
||||||
|
if (!item) {
|
||||||
|
return <View key={option.id} />;
|
||||||
|
}
|
||||||
|
const selected = selectedAttachments.some(
|
||||||
|
(attachment) =>
|
||||||
|
attachment.kind !== "image" &&
|
||||||
|
attachment.item.kind === item.kind &&
|
||||||
|
attachment.item.number === item.number,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<ComboboxItem
|
||||||
|
key={option.id}
|
||||||
|
testID={`composer-github-option-${option.id}`}
|
||||||
|
label={option.label}
|
||||||
|
selected={selected}
|
||||||
|
active={active}
|
||||||
|
onPress={() => handleToggleGithubItem(item)}
|
||||||
|
leadingSlot={
|
||||||
|
item.kind === "pr" ? (
|
||||||
|
<GitPullRequest
|
||||||
|
size={theme.iconSize.sm}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CircleDot size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -793,6 +1051,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
|||||||
messageInputContainer: {
|
messageInputContainer: {
|
||||||
position: "relative",
|
position: "relative",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
gap: theme.spacing[3],
|
||||||
},
|
},
|
||||||
autocompletePopover: {
|
autocompletePopover: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -809,11 +1068,12 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
|||||||
backgroundColor: theme.colors.palette.red[600],
|
backgroundColor: theme.colors.palette.red[600],
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
marginLeft: theme.spacing[1],
|
||||||
},
|
},
|
||||||
rightControls: {
|
rightControls: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
contextWindowMeterSlot: {
|
contextWindowMeterSlot: {
|
||||||
width: 28,
|
width: 28,
|
||||||
@@ -835,6 +1095,41 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
|||||||
iconButtonHovered: {
|
iconButtonHovered: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
|
attachmentPreviewContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
flexWrap: "wrap",
|
||||||
|
},
|
||||||
|
imageThumbnail: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
},
|
||||||
|
imageThumbnailPlaceholder: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
githubPillBody: {
|
||||||
|
minHeight: 48,
|
||||||
|
maxWidth: 260,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
},
|
||||||
|
githubPillIcon: {
|
||||||
|
width: 18,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
githubPillText: {
|
||||||
|
minWidth: 0,
|
||||||
|
flexShrink: 1,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
tooltipRow: {
|
tooltipRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
|
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
|
||||||
|
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||||
|
|
||||||
interface DiffScrollProps {
|
interface DiffScrollProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -14,12 +15,14 @@ export function DiffScroll({
|
|||||||
style,
|
style,
|
||||||
contentContainerStyle,
|
contentContainerStyle,
|
||||||
}: DiffScrollProps) {
|
}: DiffScrollProps) {
|
||||||
|
const webScrollbarStyle = useWebScrollbarStyle();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
showsHorizontalScrollIndicator
|
showsHorizontalScrollIndicator
|
||||||
style={style}
|
style={[style, webScrollbarStyle]}
|
||||||
contentContainerStyle={contentContainerStyle}
|
contentContainerStyle={contentContainerStyle}
|
||||||
onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width)}
|
onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { View, Text } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
|
||||||
|
interface DiffStatProps {
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiffStat({ additions, deletions }: DiffStatProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Text style={styles.additions}>+{additions.toLocaleString()}</Text>
|
||||||
|
<Text style={styles.deletions}>-{deletions.toLocaleString()}</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.palette.green[400],
|
||||||
|
},
|
||||||
|
deletions: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
color: theme.colors.palette.red[500],
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -4,6 +4,7 @@ import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
|||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
|
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
|
||||||
|
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||||
import { getCodeInsets } from "./code-insets";
|
import { getCodeInsets } from "./code-insets";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export function DiffViewer({
|
|||||||
fillAvailableHeight = false,
|
fillAvailableHeight = false,
|
||||||
}: DiffViewerProps) {
|
}: DiffViewerProps) {
|
||||||
const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
|
const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
|
||||||
|
const webScrollbarStyle = useWebScrollbarStyle();
|
||||||
|
|
||||||
if (!diffLines.length) {
|
if (!diffLines.length) {
|
||||||
return (
|
return (
|
||||||
@@ -38,6 +40,7 @@ export function DiffViewer({
|
|||||||
styles.verticalScroll,
|
styles.verticalScroll,
|
||||||
maxHeight !== undefined && { maxHeight },
|
maxHeight !== undefined && { maxHeight },
|
||||||
fillAvailableHeight && styles.fillHeight,
|
fillAvailableHeight && styles.fillHeight,
|
||||||
|
webScrollbarStyle,
|
||||||
]}
|
]}
|
||||||
contentContainerStyle={styles.verticalContent}
|
contentContainerStyle={styles.verticalContent}
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
@@ -47,6 +50,7 @@ export function DiffViewer({
|
|||||||
horizontal
|
horizontal
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
showsHorizontalScrollIndicator
|
showsHorizontalScrollIndicator
|
||||||
|
style={webScrollbarStyle}
|
||||||
contentContainerStyle={styles.horizontalContent}
|
contentContainerStyle={styles.horizontalContent}
|
||||||
onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)}
|
onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -167,23 +167,32 @@ export function FileExplorerPane({
|
|||||||
if (hasInitializedRef.current) {
|
if (hasInitializedRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Mark initialized eagerly so concurrent effect re-runs don't double-fetch.
|
||||||
|
// If the root listing fails (e.g. client not yet connected), we reset the
|
||||||
|
// flag so the next time requestDirectoryListing is recreated (when client
|
||||||
|
// becomes available) this effect retries automatically.
|
||||||
hasInitializedRef.current = true;
|
hasInitializedRef.current = true;
|
||||||
void requestDirectoryListing(".", {
|
void requestDirectoryListing(".", {
|
||||||
recordHistory: false,
|
recordHistory: false,
|
||||||
setCurrentPath: false,
|
setCurrentPath: false,
|
||||||
});
|
}).then((succeeded) => {
|
||||||
const persistedPaths =
|
if (!succeeded) {
|
||||||
usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
|
hasInitializedRef.current = false;
|
||||||
if (persistedPaths) {
|
return;
|
||||||
for (const path of persistedPaths) {
|
}
|
||||||
if (path !== ".") {
|
const persistedPaths =
|
||||||
void requestDirectoryListing(path, {
|
usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
|
||||||
recordHistory: false,
|
if (persistedPaths) {
|
||||||
setCurrentPath: false,
|
for (const path of persistedPaths) {
|
||||||
});
|
if (path !== ".") {
|
||||||
|
void requestDirectoryListing(path, {
|
||||||
|
recordHistory: false,
|
||||||
|
setCurrentPath: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
|
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
|
||||||
|
|
||||||
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||||
|
|
||||||
|
describe("isRenderedMarkdownFile", () => {
|
||||||
|
it("detects .md files", () => {
|
||||||
|
expect(isRenderedMarkdownFile("README.md")).toBe(true);
|
||||||
|
expect(isRenderedMarkdownFile("docs/guide.MD")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects .markdown files", () => {
|
||||||
|
expect(isRenderedMarkdownFile("notes.markdown")).toBe(true);
|
||||||
|
expect(isRenderedMarkdownFile("docs/CHANGELOG.MARKDOWN")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat .mdx files as rendered markdown", () => {
|
||||||
|
expect(isRenderedMarkdownFile("page.mdx")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat other text files as rendered markdown", () => {
|
||||||
|
expect(isRenderedMarkdownFile("src/index.ts")).toBe(false);
|
||||||
|
expect(isRenderedMarkdownFile("README.md.txt")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export function isRenderedMarkdownFile(filePath: string): boolean {
|
||||||
|
const normalizedPath = filePath.trim().toLowerCase();
|
||||||
|
return normalizedPath.endsWith(".md") || normalizedPath.endsWith(".markdown");
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useMemo, useRef } from "react";
|
import React, { useMemo, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Image as RNImage,
|
Image as RNImage,
|
||||||
@@ -12,6 +13,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
|||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||||
|
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||||
import {
|
import {
|
||||||
highlightCode,
|
highlightCode,
|
||||||
darkHighlightColors,
|
darkHighlightColors,
|
||||||
@@ -20,7 +22,9 @@ import {
|
|||||||
type HighlightStyle,
|
type HighlightStyle,
|
||||||
} from "@getpaseo/highlight";
|
} from "@getpaseo/highlight";
|
||||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||||
|
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||||
|
|
||||||
interface CodeLineProps {
|
interface CodeLineProps {
|
||||||
tokens: HighlightToken[];
|
tokens: HighlightToken[];
|
||||||
@@ -68,7 +72,7 @@ const CodeLine = React.memo(function CodeLine({
|
|||||||
<View style={[codeLineStyles.gutter, { width: gutterWidth }]}>
|
<View style={[codeLineStyles.gutter, { width: gutterWidth }]}>
|
||||||
<Text style={[codeLineStyles.gutterText, { color: baseColor }]}>{String(lineNumber)}</Text>
|
<Text style={[codeLineStyles.gutterText, { color: baseColor }]}>{String(lineNumber)}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={codeLineStyles.lineText}>
|
<Text selectable style={codeLineStyles.lineText}>
|
||||||
{tokens.map((token, index) => (
|
{tokens.map((token, index) => (
|
||||||
<Text
|
<Text
|
||||||
key={index}
|
key={index}
|
||||||
@@ -117,19 +121,23 @@ function FilePreviewBody({
|
|||||||
const isDark = theme.colorScheme === "dark";
|
const isDark = theme.colorScheme === "dark";
|
||||||
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
||||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||||
|
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||||
|
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
|
||||||
|
const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath);
|
||||||
|
|
||||||
const previewScrollRef = useRef<RNScrollView>(null);
|
const previewScrollRef = useRef<RNScrollView>(null);
|
||||||
|
const webScrollbarStyle = useWebScrollbarStyle();
|
||||||
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
|
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
|
||||||
enabled: showDesktopWebScrollbar,
|
enabled: showDesktopWebScrollbar,
|
||||||
});
|
});
|
||||||
|
|
||||||
const highlightedLines = useMemo(() => {
|
const highlightedLines = useMemo(() => {
|
||||||
if (!preview || preview.kind !== "text") {
|
if (!preview || preview.kind !== "text" || isMarkdownFile) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return highlightCode(preview.content ?? "", filePath);
|
return highlightCode(preview.content ?? "", filePath);
|
||||||
}, [preview?.kind, preview?.content, filePath]);
|
}, [isMarkdownFile, preview?.kind, preview?.content, filePath]);
|
||||||
|
|
||||||
const gutterWidth = useMemo(() => {
|
const gutterWidth = useMemo(() => {
|
||||||
if (!highlightedLines) return 0;
|
if (!highlightedLines) return 0;
|
||||||
@@ -154,6 +162,28 @@ function FilePreviewBody({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (preview.kind === "text") {
|
if (preview.kind === "text") {
|
||||||
|
if (isMarkdownFile) {
|
||||||
|
return (
|
||||||
|
<View style={styles.previewScrollContainer}>
|
||||||
|
<RNScrollView
|
||||||
|
ref={previewScrollRef}
|
||||||
|
style={styles.previewContent}
|
||||||
|
contentContainerStyle={styles.previewMarkdownScrollContent}
|
||||||
|
onLayout={scrollbar.onLayout}
|
||||||
|
onScroll={scrollbar.onScroll}
|
||||||
|
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||||
|
scrollEventThrottle={16}
|
||||||
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||||
|
>
|
||||||
|
<Markdown style={markdownStyles} markdownit={markdownParser}>
|
||||||
|
{preview.content ?? ""}
|
||||||
|
</Markdown>
|
||||||
|
</RNScrollView>
|
||||||
|
{scrollbar.overlay}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
|
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
|
||||||
const codeLines = (
|
const codeLines = (
|
||||||
<View>
|
<View>
|
||||||
@@ -188,6 +218,7 @@ function FilePreviewBody({
|
|||||||
horizontal
|
horizontal
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
showsHorizontalScrollIndicator
|
showsHorizontalScrollIndicator
|
||||||
|
style={webScrollbarStyle}
|
||||||
contentContainerStyle={styles.previewCodeScrollContent}
|
contentContainerStyle={styles.previewCodeScrollContent}
|
||||||
>
|
>
|
||||||
{codeLines}
|
{codeLines}
|
||||||
@@ -264,6 +295,7 @@ export function FilePane({
|
|||||||
return { file: payload.file ?? null, error: payload.error ?? null };
|
return { file: payload.file ?? null, error: payload.error ?? null };
|
||||||
},
|
},
|
||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
|
refetchOnMount: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -328,6 +360,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
previewCodeScrollContent: {
|
previewCodeScrollContent: {
|
||||||
padding: theme.spacing[4],
|
padding: theme.spacing[4],
|
||||||
},
|
},
|
||||||
|
previewMarkdownScrollContent: {
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
},
|
||||||
previewImageScrollContent: {
|
previewImageScrollContent: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
padding: theme.spacing[4],
|
padding: theme.spacing[4],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,8 +1,9 @@
|
|||||||
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 { usePanelStore } from "@/stores/panel-store";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
@@ -83,11 +84,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 +98,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,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: {
|
||||||
|
xs: "400",
|
||||||
|
md: "300",
|
||||||
|
},
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -33,6 +33,7 @@ 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, 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,14 +47,10 @@ 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,
|
parseServerIdFromPathname,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
@@ -220,19 +217,13 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|||||||
}, [openProjectPicker]);
|
}, [openProjectPicker]);
|
||||||
|
|
||||||
const handleSettingsMobile = useCallback(() => {
|
const handleSettingsMobile = useCallback(() => {
|
||||||
if (!activeServerId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
closeToAgent();
|
closeToAgent();
|
||||||
router.push(buildHostSettingsRoute(activeServerId));
|
router.push(buildSettingsRoute());
|
||||||
}, [activeServerId, closeToAgent]);
|
}, [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) {
|
||||||
@@ -328,44 +319,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 +347,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 +499,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 +633,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 +691,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 +842,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,305 @@
|
|||||||
|
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 { MessageInput, type AttachmentMenuItem } from "./message-input";
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Mic: createIcon("Mic"),
|
||||||
|
MicOff: createIcon("MicOff"),
|
||||||
|
ArrowUp: createIcon("ArrowUp"),
|
||||||
|
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: vi.fn(),
|
||||||
|
cancelDictation: vi.fn(),
|
||||||
|
confirmDictation: vi.fn(),
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root = null;
|
||||||
|
container = null;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderMessageInput(menuItems: AttachmentMenuItem[]) {
|
||||||
|
act(() => {
|
||||||
|
root?.render(
|
||||||
|
<MessageInput
|
||||||
|
value=""
|
||||||
|
onChangeText={vi.fn()}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
attachments={[]}
|
||||||
|
cwd="/repo"
|
||||||
|
attachmentMenuItems={menuItems}
|
||||||
|
client={{ isConnected: true } as never}
|
||||||
|
isAgentRunning
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { Mic, MicOff, ArrowUp, 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,13 +35,22 @@ 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 { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||||
|
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||||
import {
|
import {
|
||||||
markScrollInvestigationEvent,
|
markScrollInvestigationEvent,
|
||||||
markScrollInvestigationRender,
|
markScrollInvestigationRender,
|
||||||
@@ -53,21 +61,37 @@ 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;
|
||||||
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;
|
||||||
@@ -75,8 +99,8 @@ export interface MessageInputProps {
|
|||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
autoFocusKey?: string;
|
autoFocusKey?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** True when this input is the active composer. Used to gate global hotkeys and stop dictation when hidden. */
|
/** True when this composer's pane is focused. Used to gate global hotkeys and stop dictation when hidden. */
|
||||||
isInputActive?: boolean;
|
isPaneFocused?: boolean;
|
||||||
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
|
||||||
leftContent?: React.ReactNode;
|
leftContent?: React.ReactNode;
|
||||||
/** Content to render on the right side before the voice button (e.g., context window meter) */
|
/** Content to render on the right side before the voice button (e.g., context window meter) */
|
||||||
@@ -100,6 +124,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 {
|
||||||
@@ -113,8 +139,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 & {
|
||||||
@@ -181,32 +209,28 @@ 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,
|
||||||
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...",
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
autoFocusKey,
|
autoFocusKey,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
isInputActive = true,
|
isPaneFocused = true,
|
||||||
leftContent,
|
leftContent,
|
||||||
beforeVoiceContent,
|
beforeVoiceContent,
|
||||||
rightContent,
|
rightContent,
|
||||||
@@ -220,6 +244,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
onSelectionChange: onSelectionChangeCallback,
|
onSelectionChange: onSelectionChangeCallback,
|
||||||
onFocusChange,
|
onFocusChange,
|
||||||
onHeightChange,
|
onHeightChange,
|
||||||
|
inputWrapperStyle,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
@@ -232,8 +257,9 @@ 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 [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
||||||
|
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||||
const rootRef = useRef<View | null>(null);
|
const rootRef = useRef<View | null>(null);
|
||||||
const inputWrapperRef = useRef<View | null>(null);
|
const inputWrapperRef = useRef<View | null>(null);
|
||||||
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(
|
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(
|
||||||
@@ -357,15 +383,15 @@ 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -379,7 +405,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onChangeText, onSubmit, onQueue, images, isAgentRunning, defaultSendBehavior],
|
[onChangeText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDictationError = useCallback(
|
const handleDictationError = useCallback(
|
||||||
@@ -427,7 +453,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
onError: handleDictationError,
|
onError: handleDictationError,
|
||||||
canStart: canStartDictation,
|
canStart: canStartDictation,
|
||||||
canConfirm: canConfirmDictation,
|
canConfirm: canConfirmDictation,
|
||||||
autoStopWhenHidden: { isVisible: isInputActive },
|
autoStopWhenHidden: { isVisible: isPaneFocused },
|
||||||
enableDuration: true,
|
enableDuration: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -563,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).
|
||||||
@@ -891,7 +928,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
// IME composition in progress (e.g. CJK input) — all key events belong to the
|
// IME composition in progress (e.g. CJK input) — all key events belong to the
|
||||||
// IME, not the app. keyCode 229 is a Chromium fallback for when isComposing is
|
// IME, not the app. keyCode 229 is a Chromium fallback for when isComposing is
|
||||||
// cleared before the keydown fires.
|
// cleared before the keydown fires.
|
||||||
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
|
if (isImeComposingKeyboardEvent(event.nativeEvent)) return;
|
||||||
|
|
||||||
// Allow parent to intercept key events (e.g., for autocomplete navigation)
|
// Allow parent to intercept key events (e.g., for autocomplete navigation)
|
||||||
if (onKeyPressCallback) {
|
if (onKeyPressCallback) {
|
||||||
@@ -923,20 +960,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) => {
|
||||||
@@ -955,37 +995,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
|
||||||
@@ -997,10 +1010,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
accessibilityLabel="Message agent..."
|
accessibilityLabel="Message agent..."
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
isInputFocusedRef.current = true;
|
isInputFocusedRef.current = true;
|
||||||
|
setIsInputFocused(true);
|
||||||
onFocusChange?.(true);
|
onFocusChange?.(true);
|
||||||
}}
|
}}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
isInputFocusedRef.current = false;
|
isInputFocusedRef.current = false;
|
||||||
|
setIsInputFocused(false);
|
||||||
onFocusChange?.(false);
|
onFocusChange?.(false);
|
||||||
}}
|
}}
|
||||||
style={[
|
style={[
|
||||||
@@ -1025,34 +1040,69 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
autoFocus={isWeb && autoFocus}
|
autoFocus={isWeb && autoFocus}
|
||||||
/>
|
/>
|
||||||
{inputScrollbar}
|
{inputScrollbar}
|
||||||
|
{isWeb && isPaneFocused && !isInputFocused && !value && focusInputKeys ? (
|
||||||
|
<Text style={styles.focusHintText} pointerEvents="none">
|
||||||
|
{formatShortcut(focusInputKeys[0], getShortcutOs())} to focus
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Button row */}
|
{/* Button row */}
|
||||||
<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>
|
||||||
|
|
||||||
@@ -1080,13 +1130,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}>
|
||||||
@@ -1111,31 +1169,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
|
||||||
@@ -1153,7 +1186,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
</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>
|
||||||
@@ -1221,55 +1256,17 @@ 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",
|
||||||
},
|
},
|
||||||
|
focusHintText: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
textInput: {
|
textInput: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
@@ -1288,6 +1285,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,
|
||||||
@@ -1295,13 +1293,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,
|
||||||
@@ -1310,6 +1308,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,
|
||||||
@@ -1320,14 +1324,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,
|
||||||
@@ -1335,6 +1331,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,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { Alert, Text, View } from "react-native";
|
import { Alert, Text, TextInput, View } from "react-native";
|
||||||
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 { Link } from "lucide-react-native";
|
import { Link } from "lucide-react-native";
|
||||||
@@ -47,7 +47,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
export interface PairLinkModalProps {
|
export interface PairLinkModalProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
targetServerId?: string;
|
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
onSaved?: (result: {
|
onSaved?: (result: {
|
||||||
profile: HostProfile;
|
profile: HostProfile;
|
||||||
@@ -57,39 +56,39 @@ export interface PairLinkModalProps {
|
|||||||
}) => void;
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PairLinkModal({
|
export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkModalProps) {
|
||||||
visible,
|
|
||||||
onClose,
|
|
||||||
onCancel,
|
|
||||||
onSaved,
|
|
||||||
targetServerId,
|
|
||||||
}: PairLinkModalProps) {
|
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const daemons = useHosts();
|
const daemons = useHosts();
|
||||||
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
|
||||||
const [offerUrl, setOfferUrl] = useState("");
|
const offerUrlRef = useRef("");
|
||||||
|
const inputRef = useRef<TextInput>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
const clearInput = useCallback(() => {
|
||||||
|
offerUrlRef.current = "";
|
||||||
|
inputRef.current?.clear();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
setOfferUrl("");
|
clearInput();
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
onClose();
|
onClose();
|
||||||
}, [isSaving, onClose]);
|
}, [isSaving, clearInput, onClose]);
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
if (isSaving) return;
|
if (isSaving) return;
|
||||||
setOfferUrl("");
|
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 = offerUrl.trim();
|
const raw = offerUrlRef.current.trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
setErrorMessage("Paste a pairing link (…/#offer=...)");
|
setErrorMessage("Paste a pairing link (…/#offer=...)");
|
||||||
return;
|
return;
|
||||||
@@ -122,15 +121,6 @@ export function PairLinkModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetServerId && parsedOffer.serverId !== targetServerId) {
|
|
||||||
const message = `That pairing link belongs to ${parsedOffer.serverId}, not ${targetServerId}.`;
|
|
||||||
setErrorMessage(message);
|
|
||||||
if (!isMobile) {
|
|
||||||
Alert.alert("Wrong daemon", message);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
@@ -159,16 +149,7 @@ export function PairLinkModal({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDaemonFromOfferUrl]);
|
||||||
daemons,
|
|
||||||
handleClose,
|
|
||||||
isMobile,
|
|
||||||
isSaving,
|
|
||||||
offerUrl,
|
|
||||||
onSaved,
|
|
||||||
targetServerId,
|
|
||||||
upsertDaemonFromOfferUrl,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdaptiveModalSheet
|
<AdaptiveModalSheet
|
||||||
@@ -182,11 +163,13 @@ export function PairLinkModal({
|
|||||||
<View style={styles.field}>
|
<View style={styles.field}>
|
||||||
<Text style={styles.label}>Pairing link</Text>
|
<Text style={styles.label}>Pairing link</Text>
|
||||||
<AdaptiveTextInput
|
<AdaptiveTextInput
|
||||||
|
ref={inputRef}
|
||||||
testID="pair-link-input"
|
testID="pair-link-input"
|
||||||
nativeID="pair-link-input"
|
nativeID="pair-link-input"
|
||||||
accessibilityLabel="pair-link-input"
|
accessibilityLabel="pair-link-input"
|
||||||
value={offerUrl}
|
onChangeText={(next) => {
|
||||||
onChangeText={setOfferUrl}
|
offerUrlRef.current = next;
|
||||||
|
}}
|
||||||
placeholder="https://app.paseo.sh/#offer=..."
|
placeholder="https://app.paseo.sh/#offer=..."
|
||||||
placeholderTextColor={theme.colors.foregroundMuted}
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ export function ProjectPickerModal() {
|
|||||||
|
|
||||||
const recommendedPaths = useMemo(() => {
|
const recommendedPaths = useMemo(() => {
|
||||||
if (!workspaces) return [];
|
if (!workspaces) return [];
|
||||||
return Array.from(workspaces.values()).map(
|
return Array.from(workspaces.values())
|
||||||
(workspace) => workspace.projectRootPath || workspace.id,
|
.map((workspace) => workspace.projectRootPath)
|
||||||
);
|
.filter((path) => path.length > 0);
|
||||||
}, [workspaces]);
|
}, [workspaces]);
|
||||||
|
|
||||||
const directorySuggestionsQuery = useQuery({
|
const directorySuggestionsQuery = useQuery({
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { AlertCircle, Search } from "lucide-react-native";
|
||||||
import { View, Text, ActivityIndicator, ScrollView } from "react-native";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||||
|
import { SpinningRefreshIcon } from "@/components/spinning-refresh-icon";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import { Fonts } from "@/constants/theme";
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||||
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
||||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
|
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
interface ProviderDiagnosticSheetProps {
|
interface ProviderDiagnosticSheetProps {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -22,83 +27,333 @@ export function ProviderDiagnosticSheet({
|
|||||||
}: ProviderDiagnosticSheetProps) {
|
}: ProviderDiagnosticSheetProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const { entries: snapshotEntries } = useProvidersSnapshot(serverId);
|
const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId);
|
||||||
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
const providerLabel = resolveProviderLabel(provider, snapshotEntries);
|
const providerLabel = resolveProviderLabel(provider, snapshotEntries);
|
||||||
|
const providerEntry = useMemo(
|
||||||
|
() => snapshotEntries?.find((entry) => entry.provider === provider),
|
||||||
|
[snapshotEntries, provider],
|
||||||
|
);
|
||||||
|
const models = providerEntry?.models ?? [];
|
||||||
|
const providerSnapshotRefreshing = providerEntry?.status === "loading";
|
||||||
|
const providerErrorMessage =
|
||||||
|
providerEntry?.status === "error" ? (providerEntry.error ?? "Unknown error") : null;
|
||||||
|
const refreshInFlight = isRefreshing || providerSnapshotRefreshing || loading;
|
||||||
|
|
||||||
const fetchDiagnostic = useCallback(async () => {
|
const [clockTick, setClockTick] = useState(0);
|
||||||
if (!client || !provider) return;
|
useEffect(() => {
|
||||||
|
if (!visible) return;
|
||||||
|
const id = setInterval(() => setClockTick((t) => t + 1), 10_000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [visible]);
|
||||||
|
const fetchedAtLabel = useMemo(() => {
|
||||||
|
if (!providerEntry?.fetchedAt) return null;
|
||||||
|
return formatTimeAgo(new Date(providerEntry.fetchedAt));
|
||||||
|
// clockTick triggers re-computation on timer
|
||||||
|
}, [providerEntry?.fetchedAt, clockTick]);
|
||||||
|
|
||||||
setLoading(true);
|
const q = query.trim().toLowerCase();
|
||||||
setDiagnostic(null);
|
const filteredModels = q
|
||||||
|
? models.filter((m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q))
|
||||||
|
: models;
|
||||||
|
|
||||||
try {
|
const fetchDiagnostic = useCallback(
|
||||||
const result = await client.getProviderDiagnostic(provider as AgentProvider);
|
async (options?: { keepCurrent?: boolean }) => {
|
||||||
setDiagnostic(result.diagnostic);
|
if (!client || !provider) return;
|
||||||
} catch (err) {
|
|
||||||
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
|
setLoading(true);
|
||||||
} finally {
|
if (!options?.keepCurrent) {
|
||||||
setLoading(false);
|
setDiagnostic(null);
|
||||||
}
|
}
|
||||||
}, [client, provider]);
|
|
||||||
|
try {
|
||||||
|
const result = await client.getProviderDiagnostic(provider as AgentProvider);
|
||||||
|
setDiagnostic(result.diagnostic);
|
||||||
|
} catch (err) {
|
||||||
|
setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[client, provider],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
void refresh([provider as AgentProvider]);
|
||||||
|
void fetchDiagnostic({ keepCurrent: true });
|
||||||
|
}, [fetchDiagnostic, provider, refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
fetchDiagnostic();
|
fetchDiagnostic();
|
||||||
} else {
|
} else {
|
||||||
setDiagnostic(null);
|
setDiagnostic(null);
|
||||||
|
setQuery("");
|
||||||
}
|
}
|
||||||
}, [visible, fetchDiagnostic]);
|
}, [visible, fetchDiagnostic]);
|
||||||
|
|
||||||
|
function renderModelsBody() {
|
||||||
|
if (models.length === 0 && providerSnapshotRefreshing) {
|
||||||
|
return (
|
||||||
|
<View style={sheetStyles.emptyState}>
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={sheetStyles.mutedText}>Loading models…</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (models.length === 0 && providerErrorMessage) {
|
||||||
|
return (
|
||||||
|
<View style={sheetStyles.emptyState}>
|
||||||
|
<AlertCircle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (models.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={sheetStyles.emptyState}>
|
||||||
|
<Text style={sheetStyles.mutedText}>No models detected.</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filteredModels.length === 0) {
|
||||||
|
return (
|
||||||
|
<View style={sheetStyles.emptyState}>
|
||||||
|
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={sheetStyles.mutedText}>No models match your search</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return filteredModels.map((model: AgentModelDefinition, index) => (
|
||||||
|
<View key={model.id} style={[sheetStyles.modelRow, index > 0 && sheetStyles.modelRowBorder]}>
|
||||||
|
<Text style={sheetStyles.modelLabel} numberOfLines={1}>
|
||||||
|
{model.label}
|
||||||
|
</Text>
|
||||||
|
<Text style={sheetStyles.modelId} numberOfLines={1} selectable>
|
||||||
|
{model.id}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdaptiveModalSheet
|
<AdaptiveModalSheet
|
||||||
title={providerLabel}
|
title={providerLabel}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
snapPoints={["50%", "85%"]}
|
snapPoints={["50%", "85%"]}
|
||||||
>
|
scrollable={false}
|
||||||
{loading ? (
|
headerActions={
|
||||||
<View style={sheetStyles.loadingContainer}>
|
<Pressable
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
onPress={handleRefresh}
|
||||||
<Text style={sheetStyles.loadingText}>Fetching diagnostic…</Text>
|
disabled={refreshInFlight}
|
||||||
</View>
|
hitSlop={8}
|
||||||
) : diagnostic ? (
|
style={({ hovered, pressed }) => [
|
||||||
<ScrollView
|
sheetStyles.iconButton,
|
||||||
horizontal
|
(hovered || pressed) && sheetStyles.iconButtonHovered,
|
||||||
style={sheetStyles.scrollContainer}
|
refreshInFlight ? sheetStyles.disabled : null,
|
||||||
contentContainerStyle={sheetStyles.scrollContent}
|
]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Refresh ${providerLabel}`}
|
||||||
>
|
>
|
||||||
<Text style={sheetStyles.diagnosticText} selectable>
|
<SpinningRefreshIcon
|
||||||
{diagnostic}
|
spinning={refreshInFlight}
|
||||||
</Text>
|
size={theme.iconSize.sm}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View style={sheetStyles.section}>
|
||||||
|
<Text style={sheetStyles.sectionTitle}>Diagnostic</Text>
|
||||||
|
<View style={sheetStyles.codeBlock}>
|
||||||
|
{loading && !diagnostic ? (
|
||||||
|
<View style={sheetStyles.codeBlockLoading}>
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
|
<Text style={sheetStyles.mutedText}>Running diagnostic…</Text>
|
||||||
|
</View>
|
||||||
|
) : diagnostic ? (
|
||||||
|
<ScrollView
|
||||||
|
style={sheetStyles.codeScroll}
|
||||||
|
contentContainerStyle={sheetStyles.codeContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<Text style={sheetStyles.codeText} selectable>
|
||||||
|
{diagnostic}
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
<View style={sheetStyles.codeBlockLoading}>
|
||||||
|
<Text style={sheetStyles.mutedText}>No diagnostic available.</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={sheetStyles.modelsSection}>
|
||||||
|
<View style={sheetStyles.modelsHeader}>
|
||||||
|
<Text style={sheetStyles.sectionTitle}>Models</Text>
|
||||||
|
<View style={sheetStyles.modelsHeaderMeta}>
|
||||||
|
<Text style={sheetStyles.countText}>{models.length}</Text>
|
||||||
|
{fetchedAtLabel ? (
|
||||||
|
<>
|
||||||
|
<Text style={sheetStyles.metaDot}>·</Text>
|
||||||
|
<Text style={sheetStyles.countText}>Updated {fetchedAtLabel}</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{models.length > 0 ? (
|
||||||
|
<View style={sheetStyles.searchContainer}>
|
||||||
|
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||||
|
<AdaptiveTextInput
|
||||||
|
value={query}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
placeholder="Search models"
|
||||||
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
// @ts-expect-error - outlineStyle is web-only
|
||||||
|
style={[sheetStyles.searchInput, isWeb && { outlineStyle: "none" }]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
<ScrollView
|
||||||
|
style={sheetStyles.modelsScroll}
|
||||||
|
contentContainerStyle={sheetStyles.modelsScrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{renderModelsBody()}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
) : null}
|
</View>
|
||||||
</AdaptiveModalSheet>
|
</AdaptiveModalSheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sheetStyles = StyleSheet.create((theme) => ({
|
const sheetStyles = StyleSheet.create((theme) => ({
|
||||||
loadingContainer: {
|
section: {
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
},
|
||||||
|
mutedText: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
iconButton: {
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
iconButtonHovered: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
codeBlock: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
borderRadius: theme.borderRadius.base,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
overflow: "hidden",
|
||||||
|
maxHeight: 180,
|
||||||
|
},
|
||||||
|
codeScroll: {
|
||||||
|
maxHeight: 180,
|
||||||
|
},
|
||||||
|
codeContent: {
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
},
|
||||||
|
codeText: {
|
||||||
|
fontFamily: Fonts.mono,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
codeBlockLoading: {
|
||||||
|
paddingVertical: theme.spacing[4],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
modelsSection: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
modelsHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
modelsHeaderMeta: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
metaDot: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
countText: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
searchContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
},
|
||||||
|
searchInput: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
modelsScroll: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
|
modelsScrollContent: {
|
||||||
|
paddingBottom: theme.spacing[2],
|
||||||
|
},
|
||||||
|
modelRow: {
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
},
|
||||||
|
modelRowBorder: {
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
modelLabel: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
},
|
||||||
|
modelId: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontFamily: Fonts.mono,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
emptyState: {
|
||||||
paddingVertical: theme.spacing[6],
|
paddingVertical: theme.spacing[6],
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
loadingText: {
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
scrollContainer: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
scrollContent: {
|
|
||||||
paddingBottom: theme.spacing[4],
|
|
||||||
},
|
|
||||||
diagnosticText: {
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontFamily: "monospace",
|
|
||||||
lineHeight: theme.fontSize.sm * 1.6,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user