mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
2 Commits
codex/fix-
...
orchestrat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a298b8f70 | ||
|
|
bf1d5d2ab2 |
20
.github/workflows/ci.yml
vendored
20
.github/workflows/ci.yml
vendored
@@ -43,9 +43,6 @@ jobs:
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Build server dependency
|
||||
run: npm run build --workspace=@getpaseo/server
|
||||
|
||||
- name: Typecheck all packages
|
||||
run: npm run typecheck
|
||||
|
||||
@@ -102,22 +99,7 @@ jobs:
|
||||
|
||||
- name: Run Windows-critical server tests
|
||||
working-directory: packages/server
|
||||
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
|
||||
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
|
||||
|
||||
app-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
4
.github/workflows/deploy-app.yml
vendored
4
.github/workflows/deploy-app.yml
vendored
@@ -4,9 +4,9 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '!v*-beta.*'
|
||||
- '!v*-rc.*'
|
||||
- 'app-v*'
|
||||
- '!app-v*-beta.*'
|
||||
- '!app-v*-rc.*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
18
.github/workflows/desktop-release.yml
vendored
18
.github/workflows/desktop-release.yml
vendored
@@ -66,9 +66,7 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--title "Paseo $RELEASE_TAG" \
|
||||
--generate-notes \
|
||||
$prerelease_flag || {
|
||||
echo "Release creation raced with another workflow; continuing."
|
||||
}
|
||||
$prerelease_flag
|
||||
fi
|
||||
|
||||
publish-macos:
|
||||
@@ -147,7 +145,6 @@ jobs:
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
fi
|
||||
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
|
||||
@@ -157,7 +154,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mac-manifest-${{ matrix.electron_arch }}
|
||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-mac.yml
|
||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/latest-mac.yml
|
||||
retention-days: 1
|
||||
|
||||
finalize-mac-manifest:
|
||||
@@ -230,9 +227,8 @@ jobs:
|
||||
return out;
|
||||
}
|
||||
|
||||
const manifestName = `${process.env.RELEASE_CHANNEL}-mac.yml`;
|
||||
const arm64Text = fs.readFileSync(`mac-manifest-arm64/${manifestName}`, 'utf8');
|
||||
const x64Text = fs.readFileSync(`mac-manifest-x64/${manifestName}`, 'utf8');
|
||||
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8');
|
||||
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8');
|
||||
|
||||
const arm64 = parseManifest(arm64Text);
|
||||
const x64 = parseManifest(x64Text);
|
||||
@@ -247,7 +243,7 @@ jobs:
|
||||
};
|
||||
|
||||
const output = toYaml(merged);
|
||||
fs.writeFileSync(manifestName, output);
|
||||
fs.writeFileSync('latest-mac.yml', output);
|
||||
console.log('Merged manifest:\n' + output);
|
||||
NODE
|
||||
|
||||
@@ -255,7 +251,7 @@ jobs:
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "$RELEASE_TAG" "$RELEASE_CHANNEL-mac.yml" --clobber --repo "${{ github.repository }}"
|
||||
run: gh release upload "$RELEASE_TAG" latest-mac.yml --clobber --repo "${{ github.repository }}"
|
||||
|
||||
publish-linux:
|
||||
needs: [create-release]
|
||||
@@ -320,7 +316,6 @@ jobs:
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
fi
|
||||
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
||||
@@ -396,7 +391,6 @@ jobs:
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
publish_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
fi
|
||||
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
|
||||
|
||||
4
.github/workflows/release-notes-sync.yml
vendored
4
.github/workflows/release-notes-sync.yml
vendored
@@ -54,7 +54,9 @@ jobs:
|
||||
fi
|
||||
|
||||
create_if_missing="false"
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
|
||||
if [ "$EVENT_NAME" = "push" ] && [[ "$REF" == refs/tags/v* ]]; then
|
||||
create_if_missing="true"
|
||||
elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
|
||||
create_if_missing="true"
|
||||
fi
|
||||
|
||||
|
||||
68
CHANGELOG.md
68
CHANGELOG.md
@@ -1,73 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.60-beta.1 - 2026-04-20
|
||||
|
||||
### Added
|
||||
- Beta release channel for desktop updates, with a Settings toggle for receiving beta builds before they are promoted to stable.
|
||||
- Release candidates are now called beta releases, starting with `0.1.60-beta.1`.
|
||||
|
||||
## 0.1.59 - 2026-04-16
|
||||
|
||||
### Added
|
||||
- Opus 4.7 in the Claude model picker, with a 1M-context variant.
|
||||
- Extra High reasoning effort for Opus 4.7, between High and Max.
|
||||
|
||||
## 0.1.58 - 2026-04-16
|
||||
|
||||
### 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
|
||||
|
||||
### Added
|
||||
- Provider profiles — define custom providers in your Paseo config that appear alongside built-ins. Override a built-in's binary, env, or models, or create entirely new providers. See the [configuration guide](https://github.com/getpaseo/paseo/blob/main/docs/CUSTOM-PROVIDERS.md).
|
||||
- ACP agent support — add any ACP-compatible agent to Paseo with `extends: "acp"` in your provider config. No code changes needed.
|
||||
- Choose provider and model when creating scheduled agents.
|
||||
- Max reasoning effort option for Opus 4.6 models.
|
||||
- Cmd+, (Ctrl+, on Windows/Linux) opens settings.
|
||||
|
||||
### Improved
|
||||
- Git operations are dramatically faster — workspace status, PR checks, and branch data all use a shared cached snapshot service instead of shelling out to git on every request. Running 20+ workspaces simultaneously is now smooth.
|
||||
- Windows support — the daemon and CLI run natively on Windows with proper shell quoting, executable resolution, and path handling.
|
||||
- iPad and tablet layouts work correctly across all screen sizes.
|
||||
- IME composition (Chinese, Japanese, Korean input) no longer submits prematurely when pressing Enter.
|
||||
|
||||
### Fixed
|
||||
- Creating a worktree no longer briefly flashes it as a standalone project before placing it under the correct repository.
|
||||
- Worktree creation spinner stays visible throughout the process instead of disappearing on mouse-out.
|
||||
- Workspace navigation updates correctly when switching between workspaces in the same project.
|
||||
- Desktop workspace header alignment and model selector no longer overflow on narrow windows.
|
||||
- Loading indicators are visible in light mode.
|
||||
|
||||
## 0.1.54 - 2026-04-12
|
||||
|
||||
### Added
|
||||
|
||||
19
CI_STATUS.md
Normal file
19
CI_STATUS.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# CI Test Status
|
||||
|
||||
Tracking progress toward all-green CI.
|
||||
|
||||
## CI Jobs
|
||||
|
||||
| Job | Status | Notes |
|
||||
|-----|--------|-------|
|
||||
| format | unknown | `npx biome format .` |
|
||||
| typecheck | unknown | `npm run typecheck` |
|
||||
| server-tests | unknown | unit + integration (vitest) |
|
||||
| app-tests | unknown | unit tests (vitest) |
|
||||
| playwright | unknown | E2E tests (playwright) |
|
||||
| relay-tests | unknown | unit tests (vitest) |
|
||||
| cli-tests | unknown | local tests |
|
||||
|
||||
## Log
|
||||
|
||||
- 2026-04-10: Branch created, automated agents begin iterating
|
||||
@@ -24,7 +24,6 @@ This is an npm workspace monorepo:
|
||||
| [docs/TESTING.md](docs/TESTING.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/RELEASE.md](docs/RELEASE.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/CUSTOM-PROVIDERS.md](docs/CUSTOM-PROVIDERS.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/ANDROID.md](docs/ANDROID.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/DESIGN.md](docs/DESIGN.md) | How to design features before implementation |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
@@ -47,12 +46,6 @@ 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 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 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.**
|
||||
- **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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
Paseo validates the `Host` header on incoming requests against configured hostnames. Requests with unrecognized hosts are rejected.
|
||||
Paseo uses a host allowlist to validate the `Host` header on incoming requests. Requests with unrecognized hosts are rejected.
|
||||
|
||||
## Agent authentication
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ const daemon = await createPaseoDaemon(
|
||||
listen: "127.0.0.1:0", // OS picks a free port
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
hostnames: true,
|
||||
allowedHosts: true,
|
||||
mcpEnabled: false,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
|
||||
@@ -51,7 +51,7 @@ Stable tag pushes like `v0.1.0` trigger:
|
||||
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
|
||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
|
||||
|
||||
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||
Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||
|
||||
### Useful commands
|
||||
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,548 +0,0 @@
|
||||
# Custom Provider Configuration
|
||||
|
||||
Paseo supports configuring custom agent providers through `config.json` (located at `$PASEO_HOME/config.json`, typically `~/.paseo/config.json`). You can extend built-in providers with different API backends, add ACP-compatible agents, set custom binaries, disable providers, and create multiple profiles for the same underlying provider.
|
||||
|
||||
All provider configuration lives under `agents.providers` in config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"agents": {
|
||||
"providers": {
|
||||
"provider-id": { ... }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`).
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Extending a built-in provider](#extending-a-built-in-provider)
|
||||
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
|
||||
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
|
||||
- [Multiple profiles for the same provider](#multiple-profiles-for-the-same-provider)
|
||||
- [Custom binary for a provider](#custom-binary-for-a-provider)
|
||||
- [Disabling a provider](#disabling-a-provider)
|
||||
- [ACP providers](#acp-providers)
|
||||
- [Provider override reference](#provider-override-reference)
|
||||
|
||||
---
|
||||
|
||||
## Extending a built-in provider
|
||||
|
||||
Use `extends` to create a new provider entry that inherits from a built-in provider (claude, codex, copilot, opencode, pi). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-claude": {
|
||||
"extends": "claude",
|
||||
"label": "My Claude",
|
||||
"description": "Claude with custom API endpoint",
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "sk-ant-...",
|
||||
"ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Required fields for custom providers:
|
||||
- `extends` — which built-in provider to inherit from (or `"acp"`)
|
||||
- `label` — display name in the UI
|
||||
|
||||
---
|
||||
|
||||
## Z.AI (Zhipu) coding plan
|
||||
|
||||
[Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Register at [z.ai](https://z.ai) and subscribe to a coding plan
|
||||
2. Create an API key from the Z.AI dashboard
|
||||
3. Add a provider entry in config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"zai": {
|
||||
"extends": "claude",
|
||||
"label": "ZAI",
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "<your-zai-api-key>",
|
||||
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
|
||||
"API_TIMEOUT_MS": "3000000"
|
||||
},
|
||||
"models": [
|
||||
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
|
||||
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
|
||||
{ "id": "glm-5.1", "label": "GLM 5.1" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Available models
|
||||
|
||||
| Model | Tier |
|
||||
|---|---|
|
||||
| `glm-5.1` | Advanced (flagship) |
|
||||
| `glm-5-turbo` | Advanced |
|
||||
| `glm-4.7` | Standard |
|
||||
| `glm-4.5-air` | Lightweight |
|
||||
|
||||
### Notes
|
||||
|
||||
- `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)
|
||||
- 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`
|
||||
- Official docs: [docs.z.ai/devpack/tool/claude](https://docs.z.ai/devpack/tool/claude)
|
||||
|
||||
---
|
||||
|
||||
## Alibaba Cloud (Qwen) coding plan
|
||||
|
||||
[Alibaba Cloud Model Studio](https://www.alibabacloud.com/en/campaign/ai-scene-coding) offers a coding plan that routes Claude Code requests to Qwen models through an Anthropic-compatible API. Like z.ai, these are **not** Anthropic Claude models.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Go to the [Coding Plan page](https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=globalset#/efm/coding_plan) on Alibaba Cloud Model Studio (Singapore region)
|
||||
2. Subscribe to the Pro plan ($50/month)
|
||||
3. Obtain your plan-specific API key (format: `sk-sp-xxxxx`) — this is different from a standard Model Studio key
|
||||
4. Add a provider entry in config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"qwen": {
|
||||
"extends": "claude",
|
||||
"label": "Qwen (Alibaba)",
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<your-coding-plan-key>",
|
||||
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
|
||||
},
|
||||
"models": [
|
||||
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
|
||||
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" },
|
||||
{ "id": "kimi-k2.5", "label": "Kimi K2.5" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API endpoints
|
||||
|
||||
| Mode | Base URL |
|
||||
|---|---|
|
||||
| Coding plan (subscription) | `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` |
|
||||
| Pay-as-you-go (no subscription) | `https://dashscope-intl.aliyuncs.com/apps/anthropic` |
|
||||
|
||||
For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk-xxxxx`) instead of `ANTHROPIC_AUTH_TOKEN`.
|
||||
|
||||
### Available models
|
||||
|
||||
**Recommended for coding plan:**
|
||||
|
||||
| Model | Notes |
|
||||
|---|---|
|
||||
| `qwen3.5-plus` | Vision capable, recommended |
|
||||
| `qwen3-coder-next` | Optimized for coding |
|
||||
| `kimi-k2.5` | Vision capable |
|
||||
| `glm-5` | Zhipu GLM |
|
||||
| `MiniMax-M2.5` | MiniMax |
|
||||
|
||||
**Additional models (pay-as-you-go):**
|
||||
`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash`
|
||||
|
||||
### Notes
|
||||
|
||||
- API keys must be created in the **Singapore region**
|
||||
- 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)
|
||||
|
||||
---
|
||||
|
||||
## Multiple profiles for the same provider
|
||||
|
||||
You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment.
|
||||
|
||||
Example: two different Anthropic accounts as separate profiles:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"claude-work": {
|
||||
"extends": "claude",
|
||||
"label": "Claude (Work)",
|
||||
"description": "Work Anthropic account",
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "sk-ant-work-..."
|
||||
}
|
||||
},
|
||||
"claude-personal": {
|
||||
"extends": "claude",
|
||||
"label": "Claude (Personal)",
|
||||
"description": "Personal Anthropic account",
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "sk-ant-personal-..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each profile appears as a separate provider in the Paseo app. You can select which one to use when launching an agent.
|
||||
|
||||
You can also combine profiles with model overrides to pin specific models per profile:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"claude-fast": {
|
||||
"extends": "claude",
|
||||
"label": "Claude (Fast)",
|
||||
"models": [
|
||||
{ "id": "claude-sonnet-4-6", "label": "Sonnet 4.6", "isDefault": true }
|
||||
]
|
||||
},
|
||||
"claude-smart": {
|
||||
"extends": "claude",
|
||||
"label": "Claude (Smart)",
|
||||
"models": [
|
||||
{ "id": "claude-opus-4-6", "label": "Opus 4.6", "isDefault": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom binary for a provider
|
||||
|
||||
Override the command used to launch any provider with the `command` field. This is an array where the first element is the binary and the rest are arguments.
|
||||
|
||||
### Override a built-in provider's binary
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"claude": {
|
||||
"command": ["/opt/claude-nightly/claude"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Use a custom wrapper script
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"claude": {
|
||||
"command": ["/usr/local/bin/my-claude-wrapper", "--verbose"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom binary on a derived provider
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-codex": {
|
||||
"extends": "codex",
|
||||
"label": "Codex (Custom Build)",
|
||||
"command": ["/home/user/codex-dev/target/release/codex"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
|
||||
|
||||
---
|
||||
|
||||
## Disabling a provider
|
||||
|
||||
Set `enabled: false` to hide a provider from the provider list. The provider will not appear in the app or CLI.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"copilot": { "enabled": false },
|
||||
"codex": { "enabled": false }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This works for both built-in and custom providers. To re-enable, set `enabled: true` or remove the `enabled` field entirely (providers are enabled by default).
|
||||
|
||||
---
|
||||
|
||||
## ACP providers
|
||||
|
||||
The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open standard for communication between editors and AI coding agents — think LSP but for AI agents. Any agent that supports ACP can be added to Paseo as a custom provider.
|
||||
|
||||
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
|
||||
|
||||
### Adding a generic ACP provider
|
||||
|
||||
Set `extends: "acp"` and provide a `command`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-agent": {
|
||||
"extends": "acp",
|
||||
"label": "My Agent",
|
||||
"command": ["my-agent-binary", "--acp"],
|
||||
"env": {
|
||||
"MY_API_KEY": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Required fields for ACP providers:
|
||||
- `extends: "acp"`
|
||||
- `label`
|
||||
- `command` — the command to spawn the agent process (must support ACP over stdio)
|
||||
|
||||
### Example: Google Gemini CLI
|
||||
|
||||
[Gemini CLI](https://github.com/google-gemini/gemini-cli) supports ACP via the `--acp` flag.
|
||||
|
||||
1. Install: `npm install -g @anthropic-ai/gemini-cli` or see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli)
|
||||
2. Authenticate with Google (Gemini CLI handles its own auth)
|
||||
3. Add to config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"gemini": {
|
||||
"extends": "acp",
|
||||
"label": "Google Gemini",
|
||||
"command": ["gemini", "--acp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ref: [Gemini CLI ACP mode docs](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/acp-mode.md)
|
||||
|
||||
### Example: Hermes (Nous Research)
|
||||
|
||||
[Hermes](https://github.com/NousResearch/hermes-agent) is an open-source coding agent by Nous Research with persistent memory and multi-provider LLM support. It supports ACP via the `acp` subcommand.
|
||||
|
||||
1. Install: `curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash`
|
||||
2. Install ACP support: `pip install -e '.[acp]'`
|
||||
3. Configure Hermes credentials in `~/.hermes/`
|
||||
4. Add to config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"hermes": {
|
||||
"extends": "acp",
|
||||
"label": "Hermes",
|
||||
"description": "Nous Research self-improving AI agent",
|
||||
"command": ["hermes", "acp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ref: [Hermes ACP docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/acp)
|
||||
|
||||
### How ACP providers work in Paseo
|
||||
|
||||
When you launch an agent with an ACP provider:
|
||||
|
||||
1. Paseo spawns the process using the configured `command`
|
||||
2. Sends an `initialize` JSON-RPC request over stdin
|
||||
3. The agent responds with its capabilities, available modes, and models
|
||||
4. Paseo creates a session and sends prompts through the ACP protocol
|
||||
5. The agent streams responses, tool calls, and permission requests back over stdout
|
||||
|
||||
Models and modes are discovered dynamically at runtime from the agent process. If you want to override the model list (e.g., to curate which models appear in the UI), use the `models` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-agent": {
|
||||
"extends": "acp",
|
||||
"label": "My Agent",
|
||||
"command": ["my-agent", "--acp"],
|
||||
"models": [
|
||||
{ "id": "fast-model", "label": "Fast", "isDefault": true },
|
||||
{ "id": "smart-model", "label": "Smart" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Profile models (defined in config.json) completely replace runtime-discovered models when present.
|
||||
|
||||
---
|
||||
|
||||
## Provider override reference
|
||||
|
||||
Every entry under `agents.providers` accepts these fields:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
|
||||
| `label` | `string` | Yes (custom only) | Display name in the UI |
|
||||
| `description` | `string` | No | Short description shown in the UI |
|
||||
| `command` | `string[]` | Yes (ACP only) | Command to spawn 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) |
|
||||
| `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`) |
|
||||
| `order` | `number` | No | Sort order in the provider list |
|
||||
|
||||
### Model definition
|
||||
|
||||
Each entry in the `models` array:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | `string` | Yes | Model identifier sent to the provider |
|
||||
| `label` | `string` | Yes | Display name in the UI |
|
||||
| `description` | `string` | No | Short description |
|
||||
| `isDefault` | `boolean` | No | Mark as the default model selection |
|
||||
| `thinkingOptions` | `ThinkingOption[]` | No | Available thinking/reasoning levels |
|
||||
|
||||
### Thinking option
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `id` | `string` | Yes | Thinking option identifier |
|
||||
| `label` | `string` | Yes | Display name |
|
||||
| `description` | `string` | No | Short description |
|
||||
| `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
|
||||
|
||||
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`
|
||||
|
||||
Special value: `acp` — creates a generic ACP provider (requires `command`)
|
||||
|
||||
### Full example
|
||||
|
||||
A config.json with multiple custom providers:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"agents": {
|
||||
"providers": {
|
||||
"copilot": { "enabled": false },
|
||||
|
||||
"zai": {
|
||||
"extends": "claude",
|
||||
"label": "ZAI",
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "<zai-api-key>",
|
||||
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
|
||||
"API_TIMEOUT_MS": "3000000"
|
||||
},
|
||||
"models": [
|
||||
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
|
||||
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
|
||||
{ "id": "glm-5.1", "label": "GLM 5.1" }
|
||||
]
|
||||
},
|
||||
|
||||
"qwen": {
|
||||
"extends": "claude",
|
||||
"label": "Qwen (Alibaba)",
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<coding-plan-key>",
|
||||
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
|
||||
},
|
||||
"models": [
|
||||
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
|
||||
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" }
|
||||
]
|
||||
},
|
||||
|
||||
"gemini": {
|
||||
"extends": "acp",
|
||||
"label": "Google Gemini",
|
||||
"command": ["gemini", "--acp"]
|
||||
},
|
||||
|
||||
"hermes": {
|
||||
"extends": "acp",
|
||||
"label": "Hermes",
|
||||
"command": ["hermes", "acp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -130,7 +130,7 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
version: 1,
|
||||
daemon: {
|
||||
listen: "127.0.0.1:6767",
|
||||
hostnames: true | string[],
|
||||
allowedHosts: true | string[],
|
||||
mcp: { enabled: boolean },
|
||||
cors: { allowedOrigins: string[] },
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
|
||||
|
||||
@@ -35,52 +35,6 @@ In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
||||
|
||||
### Database queries
|
||||
|
||||
Run arbitrary SQL against the SQLite database:
|
||||
|
||||
```bash
|
||||
# Show table row counts
|
||||
npm run db:query
|
||||
|
||||
# Run any SQL
|
||||
npm run db:query -- "SELECT agent_id, title, last_status FROM agent_snapshots"
|
||||
npm run db:query -- "SELECT agent_id, seq, item_kind FROM agent_timeline_rows ORDER BY committed_at DESC LIMIT 10"
|
||||
|
||||
# Point at a specific DB directory
|
||||
npm run db:query -- --db /path/to/db "SELECT ..."
|
||||
```
|
||||
|
||||
Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`.
|
||||
Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens the database directly in read-only mode.
|
||||
|
||||
## paseo.json service scripts
|
||||
|
||||
Every `scripts` entry with `"type": "service"` receives these environment variables:
|
||||
|
||||
| Variable | Value |
|
||||
|---|---|
|
||||
| `PASEO_SERVICE_<NAME>_URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
|
||||
| `PASEO_SERVICE_<NAME>_PORT` | Raw ephemeral port for a declared peer service. Use only as a bypass escape hatch; it can go stale if that peer restarts. |
|
||||
| `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
|
||||
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
|
||||
| `HOST` | Bind host for the service process. |
|
||||
|
||||
`<NAME>` is normalized from the script name by uppercasing it, replacing each run of non-`A-Z0-9` characters with `_`, and trimming leading or trailing `_`. For example, `app-server` and `app.server` both normalize to `APP_SERVER`; that collision fails at spawn time with an actionable error.
|
||||
|
||||
`PORT` is not injected by default. If a framework requires `PORT`, set it in the command:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"web": {
|
||||
"type": "service",
|
||||
"command": "PORT=$PASEO_PORT npm run dev:web"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Build sync gotchas
|
||||
|
||||
### Relay → Daemon
|
||||
|
||||
@@ -95,37 +95,6 @@ Two reusable flows handle Expo dev client screens after launch:
|
||||
- `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
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
|
||||
@@ -7,7 +7,7 @@ All workspaces share one version and release together.
|
||||
There are two supported ways to ship from `main`:
|
||||
|
||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||
2. **Beta flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
|
||||
2. **Release candidate flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
|
||||
|
||||
## Standard release (patch)
|
||||
|
||||
@@ -37,32 +37,31 @@ npm run release:publish # Publish to npm
|
||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||
```
|
||||
|
||||
## Beta flow
|
||||
## Release candidate flow
|
||||
|
||||
```bash
|
||||
npm run release:beta:patch # Bump to X.Y.Z-beta.1, push commit + tag
|
||||
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag
|
||||
# ... test desktop and APK prerelease assets from GitHub Releases ...
|
||||
npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
|
||||
npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
npm run release:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ...
|
||||
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z
|
||||
```
|
||||
|
||||
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
|
||||
- Betas publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
|
||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
|
||||
- RC tags are published GitHub prereleases like `v0.1.41-rc.1`
|
||||
- RCs publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
|
||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the RC tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
|
||||
- **Do create a changelog entry for betas.** The beta entry is temporary and gets updated in place until promotion.
|
||||
- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release.
|
||||
|
||||
Use the beta path when you need to:
|
||||
Use the RC path when you need to:
|
||||
|
||||
- test a build manually in a Linux or Windows VM
|
||||
- send a build to a user who is hitting a specific problem
|
||||
- iterate on `beta.1`, `beta.2`, `beta.3`, and so on before deciding to ship broadly
|
||||
- iterate on `rc.1`, `rc.2`, `rc.3`, and so on before deciding to ship broadly
|
||||
|
||||
## Website behavior
|
||||
|
||||
- The website download page points to GitHub's latest published **stable** release.
|
||||
- Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target.
|
||||
- Published RC prereleases are public on GitHub Releases, but they do **not** become the website download target.
|
||||
- The website only moves when you publish the final stable release tag like `v0.1.41`.
|
||||
|
||||
## Fixing a failed release build
|
||||
@@ -89,13 +88,13 @@ git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.
|
||||
# Android APK
|
||||
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
||||
|
||||
# Beta
|
||||
git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
|
||||
# RC
|
||||
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --force
|
||||
```
|
||||
|
||||
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
||||
|
||||
- `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release
|
||||
- `vX.Y.Z` or `vX.Y.Z-rc.N` rebuilds the full tagged release
|
||||
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
|
||||
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
|
||||
- `android-vX.Y.Z` rebuilds the Android APK release only
|
||||
@@ -106,26 +105,24 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
|
||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||
- The website uses GitHub's latest published release API for download links, so published beta prereleases do not replace the stable download target.
|
||||
- The website uses GitHub's latest published release API for download links, so published RC prereleases do not replace the stable download target.
|
||||
|
||||
## Changelog format
|
||||
|
||||
Release notes depend on the changelog heading format. The heading **must** be strictly followed:
|
||||
Stable release notes depend on the changelog heading format. The heading **must** be strictly followed:
|
||||
|
||||
```
|
||||
## X.Y.Z - YYYY-MM-DD
|
||||
## X.Y.Z-beta.N - YYYY-MM-DD
|
||||
```
|
||||
|
||||
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
|
||||
|
||||
## Changelog policy
|
||||
|
||||
- `CHANGELOG.md` includes stable releases and the current beta line.
|
||||
- The first beta inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
|
||||
- The next beta updates that same top entry in place, for example from `0.1.60-beta.1` to `0.1.60-beta.2`.
|
||||
- Stable promotion updates that same entry in place, for example from `0.1.60-beta.2` to `0.1.60`.
|
||||
- Do not create duplicate entries for each beta on the same version line.
|
||||
- `CHANGELOG.md` is for **final stable releases only**.
|
||||
- Do not add or edit changelog entries while iterating on RCs.
|
||||
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle.
|
||||
- Between stable releases, keep changelog work out of the repo until the final release is ready.
|
||||
|
||||
## Changelog ownership
|
||||
|
||||
@@ -142,46 +139,9 @@ 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.
|
||||
- **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
|
||||
|
||||
Before cutting any release (beta 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.
|
||||
|
||||
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
|
||||
|
||||
@@ -199,10 +159,10 @@ The agent's job is a deep sanity check, not a full code review. If it flags anyt
|
||||
|
||||
The changelog always covers **stable-to-HEAD**:
|
||||
|
||||
- **Beta release**: the diff and release notes cover `latest stable tag -> HEAD`. The current beta changelog entry is updated in place.
|
||||
- **Stable release**: the same changelog entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
|
||||
- **RC release**: the diff and release notes cover `latest stable tag → HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`.
|
||||
- **Stable release**: the diff and changelog entry cover `latest stable tag → HEAD`. Any intermediate RCs are skipped — the changelog captures the full delta from the previous stable release, not just what changed since the last RC.
|
||||
|
||||
In other words, betas are checkpoints along the way; the changelog entry remains the single record for the final jump from one stable version to the next.
|
||||
In other words, RCs are checkpoints along the way; the changelog only records the final jump from one stable version to the next.
|
||||
|
||||
## Completion checklist
|
||||
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
# Unistyles Gotchas
|
||||
|
||||
This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-aware styles. Unistyles is fast because most style updates do not go through React renders: the [Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites React Native component imports, attaches style metadata, and lets the native ShadowRegistry update tracked views when theme or runtime dependencies change.
|
||||
|
||||
That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles.
|
||||
|
||||
## How Updates Propagate
|
||||
|
||||
For standard React Native components, the [Unistyles Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites imports such as `View`, `Text`, `Pressable`, and `ScrollView` to Unistyles-aware component factories. On native, those factories borrow the component ref and register the `style` prop with the ShadowRegistry. The upstream ["Why my view doesn't update?"](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update) guide describes this as the ShadowTree update path that avoids unnecessary React re-renders.
|
||||
|
||||
The important detail: the automatic native path tracks `props.style`. It does not generally track every prop that happens to carry style-like values.
|
||||
|
||||
[`useUnistyles()`](https://www.unistyl.es/v3/references/use-unistyles) is different. It gives React access to the current theme/runtime and can make a component re-render when those values change. Use it for values that must be rendered through React props, such as icon colors or small escape hatches. Do not expect direct reads from `UnistylesRuntime` to re-render a component; [issue #817](https://github.com/jpudysz/react-native-unistyles/issues/817) is a useful reminder of that invariant.
|
||||
|
||||
## Main Gotcha: `contentContainerStyle`
|
||||
|
||||
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
|
||||
|
||||
Avoid this pattern when the style depends on the theme:
|
||||
|
||||
```tsx
|
||||
<ScrollView contentContainerStyle={styles.container} />
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
On first mount this can paint with the current adaptive or initial theme. If app settings later load a persisted theme and call [`UnistylesRuntime.setTheme`](https://www.unistyl.es/v3/guides/theming#change-theme), the JS-side style proxy may report the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons.
|
||||
|
||||
This applies broadly to non-`style` props that carry theme-dependent values, such as component props named `color`, `trackColor`, `tintColor`, `backgroundStyle`, `handleIndicatorStyle`, and other library-specific style props. The [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views) recommends explicit handling for these cases, and [issue #1030](https://github.com/jpudysz/react-native-unistyles/issues/1030) shows a related native-prop update edge case around `Image.tintColor`. Treat these values as React props unless wrapped with `withUnistyles`.
|
||||
|
||||
## Fix Patterns
|
||||
|
||||
Preferred pattern: put themed backgrounds on a normal wrapper view, and keep `contentContainerStyle` theme-free.
|
||||
|
||||
```tsx
|
||||
<View style={styles.container}>
|
||||
<ScrollView contentContainerStyle={styles.contentContainer}>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
contentContainer: {
|
||||
flexGrow: 1,
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
This is the pattern used by the settings screen: the screen background lives on a normal `View style={styles.container}`, while the scroll content container only carries layout.
|
||||
|
||||
When the content container itself needs themed behavior, wrap the component with [`withUnistyles`](https://www.unistyl.es/v3/references/with-unistyles):
|
||||
|
||||
```tsx
|
||||
import { ScrollView } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
|
||||
const ThemedScrollView = withUnistyles(ScrollView);
|
||||
|
||||
<ThemedScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
/>
|
||||
```
|
||||
|
||||
`withUnistyles` extracts dependency metadata from both `style` and `contentContainerStyle`, subscribes to the relevant theme/runtime changes, and re-renders only that wrapped component when needed. Its [auto-mapping behavior for `style` and `contentContainerStyle`](https://www.unistyl.es/v3/references/with-unistyles#auto-mapping-for-style-and-contentcontainerstyle-props) is the reason it fixes themed `ScrollView` content containers. Reach for it when wrapper-view layout would be awkward or when a third-party component needs theme-aware non-`style` props mapped through Unistyles.
|
||||
|
||||
The smallest escape hatch is to use `useUnistyles()` and pass an inline value through React:
|
||||
|
||||
```tsx
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.contentContainer,
|
||||
{ backgroundColor: theme.colors.surface0 },
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
Use this sparingly. It works because React re-renders the prop, but it gives up the main Unistyles native-update path for that value.
|
||||
|
||||
## `withUnistyles` And The `> *` Child-Selector Leak
|
||||
|
||||
`withUnistyles` on a component with a theme-dependent `style` prop works by wrapping the component in a `<div style={{display: 'contents'}} className={hash}>` and emitting the style under a `.hash > *` child selector so the styles cascade onto the wrapped component. This is how auto-mapping for `style` and `contentContainerStyle` works on web.
|
||||
|
||||
The sharp edge: Unistyles hashes styles by value. If `withUnistyles` receives a style whose value is **identical** to a style used elsewhere in the app on a plain `View`, both usages get the same hash — and both CSS rules (the element rule and the `> *` child rule) are emitted under the same class name. The `> *` rule then leaks onto the direct children of every `View` that shares the hash.
|
||||
|
||||
Concrete regression we hit: `welcome-screen.tsx` had `const ThemedScrollView = withUnistyles(ScrollView)` with `style={{ flex: 1, backgroundColor: theme.colors.surface0 }}`. `agent-panel.tsx` had `root` and `container` styles with the exact same value. All three collided on class `unistyles_j2k2iilhfz`, so the browser stylesheet contained:
|
||||
|
||||
```css
|
||||
.unistyles_j2k2iilhfz { flex: 1 1 0%; background-color: var(--colors-surface0); }
|
||||
.unistyles_j2k2iilhfz > * { flex: 1 1 0%; background-color: var(--colors-surface0); }
|
||||
```
|
||||
|
||||
The child-selector rule forced `flex:1` and `background-color: surface0` onto the Composer's outer `Animated.View` (a direct child of `container`), stretching it to fill remaining space and leaving a large empty gap between the composer UI and the bottom of the screen. It also painted a `surface0` band behind the scroll-to-bottom button. The bug only appeared in the browser — Electron skips `WelcomeScreen` after pairing, so the `> *` rule was never injected there.
|
||||
|
||||
Symptoms to watch for:
|
||||
|
||||
- A sibling of a themed panel-background `View` stretches unexpectedly on web only.
|
||||
- Random direct children of a `{ flex: 1, backgroundColor: surface0 }` `View` pick up an unexpected background.
|
||||
- DevTools shows a `.unistyles_xxx > *` rule you did not write.
|
||||
|
||||
Quick confirmation in DevTools console:
|
||||
|
||||
```js
|
||||
[...document.styleSheets].flatMap(s => [...(s.cssRules || [])])
|
||||
.map(r => r.cssText).filter(t => t.includes("unistyles") && t.includes("> *"));
|
||||
```
|
||||
|
||||
Any match beyond benign `r-pointerEvents-* > *` rules from react-native-web is a leak.
|
||||
|
||||
Avoid the bug by preferring the wrapper-`View` pattern from the previous section whenever possible: put `{ flex: 1, backgroundColor: surface0 }` on a plain `View` and give the `ScrollView` a theme-free `style`/`contentContainerStyle`. That keeps `withUnistyles` off the hot path and avoids the hash collision. Only reach for `withUnistyles(ScrollView)` when a wrapper view is genuinely awkward, and when you do, give the wrapped style a distinctive shape (extra key, different layout) so it does not hash-collide with a common panel background used elsewhere.
|
||||
|
||||
## Hidden Sheet Content
|
||||
|
||||
`@gorhom/bottom-sheet` can keep `BottomSheetModal` content mounted while the sheet is hidden. That matters during Paseo's startup theme transition: a header node can be created under the initial adaptive theme, stay hidden, then appear later with stale native style values even though surrounding content has re-rendered correctly.
|
||||
|
||||
We saw this in `AdaptiveModalSheet`: the body text and buttons were dark-theme-correct, but the shared sheet title opened with the initial light-theme text color on a dark sheet background. For tiny values in a reusable sheet header, prefer the inline escape hatch:
|
||||
|
||||
```tsx
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
<Text style={[styles.title, { color: theme.colors.foreground }]}>
|
||||
{title}
|
||||
</Text>
|
||||
```
|
||||
|
||||
Keep layout and typography in `StyleSheet.create`; move only the stale theme-dependent value through React. If a larger subtree shows the same behavior, consider remounting the sheet on theme changes or moving the themed paint onto a wrapper that is mounted with the visible content.
|
||||
|
||||
The same rule applies to bottom-sheet component props such as `backgroundStyle` and `handleIndicatorStyle`: they are library props, not the direct React Native `style` prop Unistyles registers. Prefer a custom `backgroundComponent` that calls `useUnistyles()`, or pass a small inline object from the hook theme.
|
||||
|
||||
## Memoized Style Objects
|
||||
|
||||
When a third-party library receives a plain style object, it is outside Unistyles' native tracking path. Make sure any memo that builds that style object depends on the actual theme values it reads.
|
||||
|
||||
Avoid indirect keys like this:
|
||||
|
||||
```tsx
|
||||
const { theme, rt } = useUnistyles();
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);
|
||||
```
|
||||
|
||||
On adaptive system-theme changes, the hook can provide a light/dark theme update while an indirect runtime key is not the value that invalidates the memo. That leaves the library rendering stale colors. Assistant markdown hit this exact failure: the workspace shell switched to light, but assistant text and code spans kept the old dark-theme markdown style object.
|
||||
|
||||
Prefer the hook theme itself, or explicit theme tokens, as the dependency:
|
||||
|
||||
```tsx
|
||||
const { theme } = useUnistyles();
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
```
|
||||
|
||||
If a style factory is cheap, skipping `useMemo` entirely is also fine.
|
||||
|
||||
## Static Theme Imports
|
||||
|
||||
Do not import `theme` from `@/styles/theme` for live UI colors. That export is a dark-theme compatibility default, so using it in render code leaves icons, placeholders, or third-party props pinned to dark colors in light mode.
|
||||
|
||||
Use `useUnistyles()` inside the component instead:
|
||||
|
||||
```tsx
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
```
|
||||
|
||||
Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only.
|
||||
|
||||
## Adaptive Themes And Persisted Settings
|
||||
|
||||
Unistyles [`initialTheme`](https://www.unistyl.es/v3/guides/theming#select-theme) and [`adaptiveThemes`](https://www.unistyl.es/v3/guides/theming#adaptive-themes) are mutually exclusive. `initialTheme` can be a string or a synchronous function, but it cannot wait on async storage.
|
||||
|
||||
Paseo currently stores app settings in AsyncStorage and loads them through react-query. That means the app can mount under adaptive/system theme first, then switch after settings load:
|
||||
|
||||
1. Unistyles config starts with `adaptiveThemes: true`.
|
||||
2. The device may report system light.
|
||||
3. Settings load a persisted non-auto preference, such as dark.
|
||||
4. The app calls `setAdaptiveThemes(false)` and `setTheme("dark")`.
|
||||
|
||||
That brief transition is expected with the current storage model. It makes tracking-compatible styles important: anything mounted during the initial adaptive theme must update correctly after the persisted preference applies. [Issue #550](https://github.com/jpudysz/react-native-unistyles/issues/550) was a separate ScrollView sticky-header bug, but it is still useful context for why ScrollView theme updates deserve extra suspicion.
|
||||
|
||||
If we ever need to avoid the transition entirely, store at least the theme preference in synchronous storage and configure Unistyles with `initialTheme`.
|
||||
|
||||
## Debugging
|
||||
|
||||
To inspect what the Babel plugin sees, temporarily enable [`debug: true`](https://www.unistyl.es/v3/other/babel-plugin#debug) in `packages/app/babel.config.js`:
|
||||
|
||||
```js
|
||||
[
|
||||
"react-native-unistyles/plugin",
|
||||
{
|
||||
root: "src",
|
||||
debug: true,
|
||||
},
|
||||
],
|
||||
```
|
||||
|
||||
Then rebuild the bundle and look for lines such as:
|
||||
|
||||
```text
|
||||
src/components/welcome-screen.tsx: styles.container: [Theme]
|
||||
```
|
||||
|
||||
This only confirms that the stylesheet dependency was detected. The upstream debugging guide makes the same distinction: dependency detection is only one failure mode. It does not prove the style prop is registered on the native view you care about.
|
||||
|
||||
For paint-layer bugs, use high-contrast probes:
|
||||
|
||||
1. Paint each candidate layer a distinct color, such as root wrapper cyan, `ScrollView.style` yellow, and `contentContainerStyle` magenta.
|
||||
2. Cold-restart the app, not just Fast Refresh.
|
||||
3. Screenshot the simulator and sample pixels to see which color fills the area.
|
||||
4. Remove the probes before committing.
|
||||
|
||||
The welcome-screen investigation used this approach to prove the white layer was the `ScrollView` content container. Deep-dive evidence is in [welcome-theme-split-research.md](/Users/moboudra/.paseo/notes/welcome-theme-split-research.md).
|
||||
|
||||
## References
|
||||
|
||||
- [Unistyles v3 documentation](https://www.unistyl.es/)
|
||||
- [Theming: initial theme, adaptive themes, and runtime theme changes](https://www.unistyl.es/v3/guides/theming)
|
||||
- [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue)
|
||||
- [withUnistyles reference](https://www.unistyl.es/v3/references/with-unistyles)
|
||||
- [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views)
|
||||
- [Babel plugin debug option](https://www.unistyl.es/v3/other/babel-plugin#debug)
|
||||
- [Why my view doesn't update?](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update)
|
||||
- [GitHub issue #550: ScrollView sticky-header theme updates](https://github.com/jpudysz/react-native-unistyles/issues/550)
|
||||
- [GitHub issue #817: `UnistylesRuntime.themeName` does not re-render](https://github.com/jpudysz/react-native-unistyles/issues/817)
|
||||
- [GitHub issue #1030: `Image.tintColor` and native style update edge case](https://github.com/jpudysz/react-native-unistyles/issues/1030)
|
||||
- [Local research note: welcome theme split](</Users/moboudra/.paseo/notes/welcome-theme-split-research.md>)
|
||||
@@ -9,10 +9,6 @@ let
|
||||
cfg = config.services.paseo;
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
(lib.mkRenamedOptionModule [ "services" "paseo" "allowedHosts" ] [ "services" "paseo" "hostnames" ])
|
||||
];
|
||||
|
||||
options.services.paseo = {
|
||||
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
|
||||
|
||||
@@ -62,12 +58,12 @@ in
|
||||
description = "Whether to open the firewall for the Paseo daemon port.";
|
||||
};
|
||||
|
||||
hostnames = lib.mkOption {
|
||||
allowedHosts = lib.mkOption {
|
||||
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
|
||||
default = [ ];
|
||||
example = [ ".example.com" "myhost.local" ];
|
||||
description = ''
|
||||
Hostnames the Paseo daemon accepts in the Host header (DNS rebinding protection).
|
||||
Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
|
||||
Localhost and IP addresses are always allowed by default.
|
||||
|
||||
Use a leading dot to match a domain and all its subdomains
|
||||
@@ -145,10 +141,10 @@ in
|
||||
"/run/wrappers/bin"
|
||||
"/nix/var/nix/profiles/default/bin"
|
||||
]);
|
||||
} // lib.optionalAttrs (cfg.hostnames == true) {
|
||||
PASEO_HOSTNAMES = "true";
|
||||
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
|
||||
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
|
||||
} // lib.optionalAttrs (cfg.allowedHosts == true) {
|
||||
PASEO_ALLOWED_HOSTS = "true";
|
||||
} // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
|
||||
PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
|
||||
} // cfg.environment;
|
||||
|
||||
serviceConfig = {
|
||||
|
||||
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-6v597rirYsPQJYXvVpd0+MZfbY0I6Oqlmd/7z5eHiOw=";
|
||||
npmDepsHash = "sha256-sVWb8lj3cgxgVcRvBmS5f0AyPT6uS46KfBEWgG6s8ww=";
|
||||
|
||||
# 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).
|
||||
|
||||
3090
package-lock.json
generated
3090
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
34
package.json
34
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.60-beta.1",
|
||||
"version": "0.1.55-rc.1",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
@@ -14,7 +14,6 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "./scripts/dev.sh",
|
||||
"dev:win": "powershell ./scripts/dev.ps1",
|
||||
"dev:server": "npm run dev --workspace=@getpaseo/server",
|
||||
"dev:app": "npm run start --workspace=@getpaseo/app",
|
||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||
@@ -37,9 +36,7 @@
|
||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
"dev:desktop": "npm run dev --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",
|
||||
"db:query": "npm run db:query --workspace=@getpaseo/server --",
|
||||
"cli": "npx tsx packages/cli/src/index.js",
|
||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||
@@ -47,19 +44,19 @@
|
||||
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
|
||||
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
|
||||
"version:all:major": "node scripts/set-release-version.mjs --mode major",
|
||||
"version:all:beta:patch": "node scripts/set-release-version.mjs --mode beta-patch",
|
||||
"version:all:beta:minor": "node scripts/set-release-version.mjs --mode beta-minor",
|
||||
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
|
||||
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
|
||||
"version:all:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch",
|
||||
"version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor",
|
||||
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major",
|
||||
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next",
|
||||
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
|
||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:push",
|
||||
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:push",
|
||||
"release:beta:major": "npm run release:check && npm run version:all:beta:major && npm run release:push",
|
||||
"release:beta:next": "npm run release:check && npm run version:all:beta:next && npm run release:push",
|
||||
"release:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push",
|
||||
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push",
|
||||
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push",
|
||||
"release:rc:next": "npm run release:check && npm run version:all:rc:next && npm run release:push",
|
||||
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
|
||||
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
||||
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
||||
@@ -71,8 +68,8 @@
|
||||
"get-port-cli": "^3.0.0",
|
||||
"knip": "^5.82.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react": "19.1.4",
|
||||
"react-dom": "19.1.4",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
@@ -96,14 +93,11 @@
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"overrides": {
|
||||
"lightningcss": "1.30.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
"react": "19.1.4",
|
||||
"react-dom": "19.1.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"expo": "~54.0.33",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5"
|
||||
"@modelcontextprotocol/sdk": "^1.27.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
createIdleAgent,
|
||||
expectSessionRowVisible,
|
||||
expectWorkspaceArchiveOutcome,
|
||||
expectWorkspaceTabHidden,
|
||||
openSessions,
|
||||
openWorkspaceWithAgents,
|
||||
primeAdditionalPage,
|
||||
@@ -20,7 +19,7 @@ test.describe("Archive tab reconciliation", () => {
|
||||
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
|
||||
test.describe.configure({ timeout: 300_000 });
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("archive-tab-");
|
||||
@@ -65,7 +64,10 @@ test.describe("Archive tab reconciliation", () => {
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
await reloadWorkspace(passivePage, tempRepo.path);
|
||||
await expectWorkspaceTabHidden(passivePage, archived.id);
|
||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
} finally {
|
||||
await passivePage.close();
|
||||
}
|
||||
@@ -91,7 +93,10 @@ test.describe("Archive tab reconciliation", () => {
|
||||
await openSessions(page);
|
||||
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
|
||||
await reloadWorkspace(page, tempRepo.path);
|
||||
await expectWorkspaceTabHidden(page, archived.id);
|
||||
await expectWorkspaceArchiveOutcome(page, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
collapseFolder,
|
||||
expandFolder,
|
||||
expectExplorerEntryHidden,
|
||||
expectExplorerEntryVisible,
|
||||
expectFileTabOpen,
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
} from "./helpers/file-explorer";
|
||||
import { gotoWorkspace } from "./helpers/launcher";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
type WorkspaceSetupDaemonClient,
|
||||
} from "./helpers/workspace-setup";
|
||||
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
let workspaceId: string;
|
||||
let seedClient: WorkspaceSetupDaemonClient;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("file-explorer-collapse-", {
|
||||
files: [
|
||||
{ path: "assets/logo.png", content: "image bytes for explorer e2e\n" },
|
||||
{ path: "docs/guide.md", content: "# Guide\n" },
|
||||
],
|
||||
});
|
||||
seedClient = await connectWorkspaceSetupClient();
|
||||
const result = await seedClient.openProject(tempRepo.path);
|
||||
if (!result.workspace) {
|
||||
throw new Error(result.error ?? "Failed to seed workspace");
|
||||
}
|
||||
workspaceId = String(result.workspace.id);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await seedClient?.close();
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
test.describe("File explorer collapse", () => {
|
||||
test("collapses an opened image file parent folder and still expands other folders", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
await expandFolder(page, "assets");
|
||||
await expectExplorerEntryVisible(page, "logo.png");
|
||||
|
||||
await openFileFromExplorer(page, "logo.png");
|
||||
await expectFileTabOpen(page, "assets/logo.png");
|
||||
|
||||
await collapseFolder(page, "assets");
|
||||
await expectExplorerEntryHidden(page, "logo.png");
|
||||
|
||||
await expandFolder(page, "docs");
|
||||
await expectExplorerEntryVisible(page, "guide.md");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
|
||||
import { spawn, type ChildProcess, execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import net from "node:net";
|
||||
@@ -182,7 +182,6 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
|
||||
let daemonProcess: ChildProcess | null = null;
|
||||
let metroProcess: ChildProcess | null = null;
|
||||
let paseoHome: string | null = null;
|
||||
let fakeGhBinDir: string | null = null;
|
||||
let relayProcess: ChildProcess | null = null;
|
||||
|
||||
type OfferPayload = {
|
||||
@@ -192,47 +191,6 @@ type OfferPayload = {
|
||||
relay: { endpoint: string };
|
||||
};
|
||||
|
||||
async function createFakeGhBin(): Promise<string> {
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-"));
|
||||
const ghPath = path.join(binDir, "gh");
|
||||
await writeFile(
|
||||
ghPath,
|
||||
`#!/usr/bin/env node
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args[0] === "auth" && args[1] === "status") {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args[0] === "pr" && args[1] === "list") {
|
||||
console.log(JSON.stringify([
|
||||
{
|
||||
number: 515,
|
||||
title: "Review selected start ref",
|
||||
url: "https://github.com/getpaseo/paseo/pull/515",
|
||||
state: "OPEN",
|
||||
body: "Fixture pull request for app e2e.",
|
||||
labels: [],
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/start-from-pr"
|
||||
}
|
||||
]));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args[0] === "issue" && args[1] === "list") {
|
||||
console.log("[]");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error("Unsupported fake gh invocation: " + args.join(" "));
|
||||
process.exit(1);
|
||||
`,
|
||||
);
|
||||
await chmod(ghPath, 0o755);
|
||||
return binDir;
|
||||
}
|
||||
|
||||
function stripAnsi(input: string): string {
|
||||
return input.replace(/\u001b\[[0-9;]*m/g, "");
|
||||
}
|
||||
@@ -266,51 +224,6 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
||||
return offer as OfferPayload;
|
||||
}
|
||||
|
||||
function loadPairingOfferFromCli(repoRoot: string, paseoHomePath: string): OfferPayload {
|
||||
const stdout = execFileSync(
|
||||
process.execPath,
|
||||
["--import", "tsx", "packages/cli/src/index.ts", "daemon", "pair", "--json"],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHomePath,
|
||||
},
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
const payload = JSON.parse(stdout) as { relayEnabled?: boolean; url?: string | null };
|
||||
if (payload.relayEnabled !== true || typeof payload.url !== "string") {
|
||||
throw new Error(`Unexpected daemon pair response: ${stdout}`);
|
||||
}
|
||||
return decodeOfferFromFragmentUrl(payload.url);
|
||||
}
|
||||
|
||||
async function waitForPairingOfferFromCli(args: {
|
||||
repoRoot: string;
|
||||
paseoHome: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<OfferPayload> {
|
||||
const timeoutMs = args.timeoutMs ?? 15000;
|
||||
const start = Date.now();
|
||||
let lastError: unknown = null;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
return loadPairingOfferFromCli(args.repoRoot, args.paseoHome);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out waiting for \`paseo daemon pair --json\` to produce a pairing offer: ${
|
||||
lastError instanceof Error ? lastError.message : String(lastError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
export default async function globalSetup() {
|
||||
const repoRoot = path.resolve(__dirname, "../../..");
|
||||
ensureRelayBuildArtifact(repoRoot);
|
||||
@@ -323,7 +236,6 @@ export default async function globalSetup() {
|
||||
let relayPort = 0;
|
||||
const metroPort = await getAvailablePort();
|
||||
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
|
||||
fakeGhBinDir = await createFakeGhBin();
|
||||
let relayLineBuffer = createLineBuffer();
|
||||
const metroLineBuffer = createLineBuffer();
|
||||
const daemonLineBuffer = createLineBuffer();
|
||||
@@ -341,10 +253,6 @@ export default async function globalSetup() {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
paseoHome = null;
|
||||
}
|
||||
if (fakeGhBinDir) {
|
||||
await rm(fakeGhBinDir, { recursive: true, force: true });
|
||||
fakeGhBinDir = null;
|
||||
}
|
||||
};
|
||||
|
||||
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
|
||||
@@ -525,11 +433,16 @@ export default async function globalSetup() {
|
||||
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
|
||||
let offerPayload: OfferPayload | null = null;
|
||||
let offerResolve: (() => void) | null = null;
|
||||
const offerPromise = new Promise<void>((resolve) => {
|
||||
offerResolve = resolve;
|
||||
});
|
||||
|
||||
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||
@@ -560,6 +473,26 @@ export default async function globalSetup() {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
daemonLineBuffer.add(`[stdout] ${trimmed}`);
|
||||
if (!offerPayload) {
|
||||
const clean = stripAnsi(trimmed);
|
||||
try {
|
||||
const obj = JSON.parse(clean) as { msg?: string; url?: string };
|
||||
if (obj.msg === "pairing_offer" && typeof obj.url === "string") {
|
||||
offerPayload = decodeOfferFromFragmentUrl(obj.url);
|
||||
offerResolve?.();
|
||||
}
|
||||
} catch {
|
||||
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
|
||||
if (match && clean.includes("pairing_offer")) {
|
||||
try {
|
||||
offerPayload = decodeOfferFromFragmentUrl(match[0]);
|
||||
offerResolve?.();
|
||||
} catch {
|
||||
// ignore parsing failures
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[daemon] ${trimmed}`);
|
||||
}
|
||||
});
|
||||
@@ -590,10 +523,17 @@ export default async function globalSetup() {
|
||||
}),
|
||||
]);
|
||||
|
||||
const offer = await waitForPairingOfferFromCli({
|
||||
repoRoot,
|
||||
paseoHome,
|
||||
});
|
||||
// Wait for daemon to emit a pairing offer (includes relay session ID).
|
||||
await Promise.race([
|
||||
offerPromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Timed out waiting for pairing_offer log")), 15000),
|
||||
),
|
||||
]);
|
||||
if (!offerPayload) {
|
||||
throw new Error("pairing_offer was not parsed from daemon logs");
|
||||
}
|
||||
const offer = offerPayload as OfferPayload;
|
||||
|
||||
process.env.E2E_DAEMON_PORT = String(port);
|
||||
process.env.E2E_RELAY_PORT = String(relayPort);
|
||||
|
||||
@@ -200,11 +200,16 @@ export const gotoHome = 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.
|
||||
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(settingsButton).toBeVisible();
|
||||
await settingsButton.click();
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await expect(page).toHaveURL(new RegExp(`/h/${escapeRegex(serverId)}/settings$`));
|
||||
};
|
||||
|
||||
export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
|
||||
@@ -27,15 +27,10 @@ type ArchiveTabDaemonClient = {
|
||||
modeId: string;
|
||||
cwd: string;
|
||||
title: string;
|
||||
initialPrompt?: string;
|
||||
initialPrompt: string;
|
||||
}): Promise<{ id: string }>;
|
||||
archiveAgent(agentId: string): Promise<{ archivedAt: 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 {
|
||||
@@ -115,17 +110,16 @@ export async function createIdleAgent(
|
||||
const created = await client.createAgent({
|
||||
provider: "opencode",
|
||||
model: "opencode/gpt-5-nano",
|
||||
modeId: "bypassPermissions",
|
||||
modeId: "default",
|
||||
cwd: input.cwd,
|
||||
title: input.title,
|
||||
initialPrompt: "Reply with exactly READY.",
|
||||
});
|
||||
const snapshot = await client.waitForAgentUpsert(
|
||||
created.id,
|
||||
(agent) => agent.status === "idle",
|
||||
30_000,
|
||||
);
|
||||
if (snapshot.status !== "idle") {
|
||||
throw new Error(`Expected agent ${created.id} to become idle, got ${snapshot.status}.`);
|
||||
const finished = await client.waitForFinish(created.id, 120_000);
|
||||
if (finished.status !== "idle") {
|
||||
throw new Error(
|
||||
`Expected agent ${created.id} to become idle, got ${finished.status}. Error: ${JSON.stringify((finished as Record<string, unknown>).error ?? "unknown")}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: created.id,
|
||||
@@ -194,31 +188,19 @@ export async function openWorkspaceWithAgents(
|
||||
const serverId = getServerId();
|
||||
for (const agent of agents) {
|
||||
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 expectWorkspaceTabVisible(page, agent.id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }),
|
||||
).toHaveCount(0, {
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -272,19 +254,13 @@ export async function archiveAgentFromSessions(
|
||||
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.down();
|
||||
await page.waitForTimeout(900);
|
||||
await page.mouse.up();
|
||||
|
||||
// If a confirmation modal appears (running agent), click the archive button.
|
||||
const archiveButton = page.getByTestId("agent-action-archive").first();
|
||||
const modalVisible = await archiveButton.isVisible().catch(() => false);
|
||||
if (modalVisible) {
|
||||
await archiveButton.click();
|
||||
}
|
||||
|
||||
await expect(archiveButton).toBeVisible({ timeout: 10_000 });
|
||||
await archiveButton.click();
|
||||
await expectSessionRowArchived(page, input.title);
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
function fileExplorerTree(page: Page) {
|
||||
return page.getByTestId("file-explorer-tree-scroll");
|
||||
}
|
||||
|
||||
function fileExplorerEntry(page: Page, name: string) {
|
||||
return fileExplorerTree(page).getByText(name, { exact: true }).first();
|
||||
}
|
||||
|
||||
export async function openFileExplorer(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open explorer" }).first().click();
|
||||
await page.getByTestId("explorer-tab-files").click();
|
||||
await expect(fileExplorerTree(page)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expandFolder(page: Page, folderName: string): Promise<void> {
|
||||
await fileExplorerEntry(page, folderName).click();
|
||||
}
|
||||
|
||||
export async function collapseFolder(page: Page, folderName: string): Promise<void> {
|
||||
await fileExplorerEntry(page, folderName).click();
|
||||
}
|
||||
|
||||
export async function openFileFromExplorer(page: Page, fileName: string): Promise<void> {
|
||||
await fileExplorerEntry(page, fileName).click();
|
||||
}
|
||||
|
||||
export async function expectExplorerEntryVisible(page: Page, name: string): Promise<void> {
|
||||
await expect(fileExplorerEntry(page, name)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectExplorerEntryHidden(page: Page, name: string): Promise<void> {
|
||||
await expect(fileExplorerEntry(page, name)).toBeHidden({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectFileTabOpen(page: Page, filePath: string): Promise<void> {
|
||||
await expect(page.getByTestId(`workspace-tab-file_${filePath}`).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
|
||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
/** Navigate to a workspace and wait for the tab bar to appear. */
|
||||
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
|
||||
const route = buildHostWorkspaceRoute(getServerId(), cwd);
|
||||
await page.goto(route);
|
||||
await waitForTabBar(page);
|
||||
}
|
||||
|
||||
// ─── Tab bar queries ───────────────────────────────────────────────────────
|
||||
|
||||
/** Wait for the workspace tab bar to be visible. */
|
||||
export async function waitForTabBar(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
|
||||
).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Return all tab test IDs currently in the tab bar. */
|
||||
export async function getTabTestIds(page: Page): Promise<string[]> {
|
||||
const tabs = page
|
||||
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
|
||||
.filter({ visible: true });
|
||||
const count = await tabs.count();
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const testId = await tabs.nth(i).getAttribute("data-testid");
|
||||
if (testId) ids.push(testId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Return the number of tabs matching a kind prefix (e.g. "launcher", "draft", "terminal", "agent"). */
|
||||
export async function countTabsOfKind(page: Page, kind: string): Promise<number> {
|
||||
const ids = await getTabTestIds(page);
|
||||
return ids.filter((id) => id.includes(kind)).length;
|
||||
}
|
||||
|
||||
/** Return the currently active tab's test ID (the one with aria-selected or focus styling). */
|
||||
export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
||||
// Active tab has the focused highlight — check for the aria-selected or data-active attribute
|
||||
const activeTab = page
|
||||
.locator(
|
||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])[aria-selected="true"]',
|
||||
)
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
if (await activeTab.isVisible().catch(() => false)) {
|
||||
return activeTab.getAttribute("data-testid");
|
||||
}
|
||||
// Fallback: the tab with focused styling
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Tab actions ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */
|
||||
export async function clickNewTabButton(page: Page): Promise<void> {
|
||||
const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */
|
||||
export async function clickNewTerminalButton(page: Page): Promise<void> {
|
||||
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open a new tab. */
|
||||
export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+t`);
|
||||
}
|
||||
|
||||
// ─── Tab bar assertions ───────────────────────────────────────────────────
|
||||
|
||||
/** @deprecated The launcher panel was removed. Actions go directly to their target. */
|
||||
export async function waitForLauncherPanel(page: Page): Promise<void> {
|
||||
// No-op: the launcher panel no longer exists.
|
||||
}
|
||||
|
||||
/** Assert the new agent tab button is visible in the tab bar. */
|
||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
/** Assert the new terminal button is visible in the tab bar. */
|
||||
export async function assertTerminalTileVisible(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-terminal").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
// ─── Tab creation actions ─────────────────────────────────────────────────
|
||||
|
||||
/** Click the new agent tab button to create a draft/chat tab. */
|
||||
export async function clickNewChat(page: Page): Promise<void> {
|
||||
const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/** Click the new terminal button to create a terminal tab. */
|
||||
export async function clickTerminal(page: Page): Promise<void> {
|
||||
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
// ─── Tab title assertions ──────────────────────────────────────────────────
|
||||
|
||||
/** Wait for any tab in the bar to display the given title text. */
|
||||
export async function waitForTabWithTitle(
|
||||
page: Page,
|
||||
title: string | RegExp,
|
||||
timeout = 30_000,
|
||||
): Promise<void> {
|
||||
const matcher = typeof title === "string" ? new RegExp(title, "i") : title;
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
|
||||
.filter({ hasText: matcher })
|
||||
.filter({ visible: true })
|
||||
.first(),
|
||||
).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
/** Assert the new agent tab button is visible in the tab bar. */
|
||||
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
||||
const buttons = page.getByTestId("workspace-new-agent-tab").filter({ visible: true });
|
||||
const count = await buttons.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
// ─── No-flash measurement ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Measure the time between clicking a launcher tile and the replacement panel becoming visible.
|
||||
* Returns elapsed milliseconds.
|
||||
*/
|
||||
export async function measureTileTransition(
|
||||
page: Page,
|
||||
clickAction: () => Promise<void>,
|
||||
successLocator: ReturnType<Page["locator"]>,
|
||||
timeout = 5_000,
|
||||
): Promise<number> {
|
||||
const start = Date.now();
|
||||
await clickAction();
|
||||
await expect(successLocator).toBeVisible({ timeout });
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample tab IDs at high frequency across a transition to detect blank/intermediate states.
|
||||
* Returns all unique snapshots observed.
|
||||
*/
|
||||
export async function sampleTabsDuringTransition(
|
||||
page: Page,
|
||||
action: () => Promise<void>,
|
||||
durationMs = 2_000,
|
||||
intervalMs = 30,
|
||||
): Promise<string[][]> {
|
||||
const snapshots: string[][] = [];
|
||||
const startSampling = async () => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < durationMs) {
|
||||
snapshots.push(await getTabTestIds(page));
|
||||
await page.waitForTimeout(intervalMs);
|
||||
}
|
||||
};
|
||||
|
||||
const samplingPromise = startSampling();
|
||||
await action();
|
||||
await samplingPromise;
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
// ─── Workspace setup ───────────────────────────────────────────────────────
|
||||
|
||||
/** Create a temp git repo and return its path with a cleanup function. */
|
||||
export async function createWorkspace(
|
||||
prefix = "launcher-e2e-",
|
||||
): ReturnType<typeof createTempGitRepo> {
|
||||
return createTempGitRepo(prefix);
|
||||
}
|
||||
@@ -84,6 +84,21 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
|
||||
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> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
@@ -153,7 +168,7 @@ export async function createWorktreeViaDaemon(
|
||||
};
|
||||
}
|
||||
|
||||
export async function openNewWorkspaceComposer(
|
||||
export async function clickNewWorkspaceButton(
|
||||
page: Page,
|
||||
input: { projectKey: string; projectDisplayName: string },
|
||||
): Promise<void> {
|
||||
@@ -164,80 +179,42 @@ export async function openNewWorkspaceComposer(
|
||||
const button = page.getByTestId(`sidebar-project-new-worktree-${input.projectKey}`).first();
|
||||
await expect(button).toBeVisible({ timeout: 30_000 });
|
||||
await button.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clickNewWorkspaceButton(
|
||||
page: Page,
|
||||
input: { projectKey: string; projectDisplayName: string },
|
||||
): Promise<void> {
|
||||
await openNewWorkspaceComposer(page, input);
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||
await createButton.click();
|
||||
}
|
||||
|
||||
export async function openStartingRefPicker(page: Page): Promise<void> {
|
||||
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
}
|
||||
|
||||
export async function selectBranchInPicker(page: Page, name: string): Promise<void> {
|
||||
const branchRow = page.getByTestId(`new-workspace-ref-picker-branch-${name}`);
|
||||
await expect(branchRow).toBeVisible({ timeout: 30_000 });
|
||||
await branchRow.click();
|
||||
}
|
||||
|
||||
export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
|
||||
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
|
||||
await expect(prRow).toBeVisible({ timeout: 30_000 });
|
||||
await prRow.click();
|
||||
}
|
||||
|
||||
export async function expectStartingRefPickerTriggerPr(
|
||||
page: Page,
|
||||
input: { number: number; title: string; headRef: string },
|
||||
): Promise<void> {
|
||||
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
|
||||
await expect(trigger).toContainText(`#${input.number}`);
|
||||
await expect(trigger).toContainText(input.title);
|
||||
await expect(trigger).not.toContainText(input.headRef);
|
||||
}
|
||||
|
||||
export async function expectComposerGithubAttachmentPill(
|
||||
page: Page,
|
||||
input: { number: number; title: string },
|
||||
): Promise<void> {
|
||||
const pills = page.getByTestId("composer-github-attachment-pill");
|
||||
await expect(pills).toHaveCount(1);
|
||||
await expect(pills.first()).toContainText(`#${input.number}`);
|
||||
await expect(pills.first()).toContainText(input.title);
|
||||
}
|
||||
|
||||
export async function assertNewWorkspaceSidebarAndHeader(
|
||||
page: Page,
|
||||
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: 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;
|
||||
const deadline = Date.now() + 60_000;
|
||||
const sidebarWorkspaceRows = page.locator(
|
||||
`[data-testid^="sidebar-workspace-row-${input.serverId}:"]`,
|
||||
);
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
|
||||
if (workspaceId && workspaceId !== input.previousWorkspaceId) {
|
||||
const sidebarRowTestIds = await sidebarWorkspaceRows.evaluateAll((elements) =>
|
||||
elements.map((element) => element.getAttribute("data-testid") ?? ""),
|
||||
);
|
||||
workspaceId =
|
||||
sidebarRowTestIds
|
||||
.map((testId) => parseWorkspaceIdFromSidebarRowTestId(testId, input))
|
||||
.find((id) => id !== null) ?? null;
|
||||
if (workspaceId) {
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
|
||||
throw new Error(`Expected URL to redirect to a new workspace.\nCurrent URL: ${page.url()}`);
|
||||
const sidebarWorkspaceRowIds = await sidebarWorkspaceRows.evaluateAll((elements) =>
|
||||
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(
|
||||
|
||||
@@ -8,10 +8,6 @@ import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
export type TerminalPerfDaemonClient = {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
openProject(cwd: string): Promise<{
|
||||
workspace: { id: string; name: string; projectRootPath: string } | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
createTerminal(
|
||||
cwd: string,
|
||||
name?: string,
|
||||
@@ -83,14 +79,14 @@ export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient>
|
||||
return client;
|
||||
}
|
||||
|
||||
export function buildTerminalWorkspaceUrl(workspaceId: string, terminalId: string): string {
|
||||
export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): string {
|
||||
const serverId = getServerId();
|
||||
const route = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
const route = buildHostWorkspaceRoute(serverId, cwd);
|
||||
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
|
||||
}
|
||||
|
||||
function buildWorkspaceUrl(workspaceId: string): string {
|
||||
return buildHostWorkspaceRoute(getServerId(), workspaceId);
|
||||
function buildWorkspaceUrl(cwd: string): string {
|
||||
return buildHostWorkspaceRoute(getServerId(), cwd);
|
||||
}
|
||||
|
||||
export async function getTerminalBufferText(page: Page): Promise<string> {
|
||||
@@ -129,35 +125,31 @@ export async function waitForTerminalContent(
|
||||
|
||||
export async function navigateToTerminal(
|
||||
page: Page,
|
||||
input: { workspaceId: string; terminalId: string },
|
||||
input: { cwd: string; terminalId: string },
|
||||
): Promise<void> {
|
||||
// Boot the app at the workspace route directly.
|
||||
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
|
||||
// so the daemon registry is already configured when the app starts.
|
||||
const workspaceRoute = buildTerminalWorkspaceUrl(input.workspaceId, input.terminalId);
|
||||
const workspaceRoute = buildTerminalWorkspaceUrl(input.cwd, input.terminalId);
|
||||
await page.goto(workspaceRoute);
|
||||
|
||||
// The workspace layout consumes `?open=...`, 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.
|
||||
const cleanWorkspaceRoute = buildWorkspaceUrl(input.workspaceId);
|
||||
const cleanWorkspaceRoute = buildWorkspaceUrl(input.cwd);
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname === cleanWorkspaceRoute && !url.searchParams.has("open"),
|
||||
{ timeout: 30_000 },
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
// Wait for daemon connection (sidebar shows host label)
|
||||
await page
|
||||
.getByText("localhost", { exact: true })
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 30_000 });
|
||||
.waitFor({ state: "visible", timeout: 15_000 });
|
||||
|
||||
// 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}"]`);
|
||||
await terminalTab.waitFor({ state: "visible", timeout: 30_000 });
|
||||
await terminalTab.waitFor({ state: "visible", timeout: 15_000 });
|
||||
await terminalTab.click();
|
||||
|
||||
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import type { SessionOutboundMessage } from "@server/shared/messages";
|
||||
|
||||
type WorkspaceSetupDaemonClient = {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
openProject(cwd: string): Promise<{
|
||||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceDirectory: string;
|
||||
projectRootPath: string;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
createPaseoWorktree(input: { cwd: string; worktreeSlug?: string }): Promise<{
|
||||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceDirectory: string;
|
||||
projectRootPath: string;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
fetchWorkspaces(): Promise<{
|
||||
entries: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceDirectory: string;
|
||||
projectRootPath: string;
|
||||
}>;
|
||||
}>;
|
||||
fetchAgents(): Promise<{
|
||||
entries: Array<{
|
||||
agent: { id: string; cwd: string; workspaceId?: string | null };
|
||||
}>;
|
||||
}>;
|
||||
fetchAgent(agentId: string): Promise<{
|
||||
agent: { id: string; cwd: string } | null;
|
||||
project: unknown | null;
|
||||
} | null>;
|
||||
listTerminals(cwd: string): Promise<{
|
||||
cwd?: string;
|
||||
terminals: Array<{ id: string; cwd: string; name: string }>;
|
||||
error?: string | null;
|
||||
}>;
|
||||
subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void;
|
||||
};
|
||||
|
||||
export type WorkspaceSetupProgressPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace_setup_progress" }
|
||||
>["payload"];
|
||||
|
||||
export type { WorkspaceSetupDaemonClient };
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
return `ws://127.0.0.1:${daemonPort}/ws`;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}) => WorkspaceSetupDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}) => WorkspaceSetupDaemonClient;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `workspace-setup-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
webSocketFactory,
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function seedProjectForWorkspaceSetup(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
repoPath: string,
|
||||
): Promise<void> {
|
||||
const result = await client.openProject(repoPath);
|
||||
if (!result.workspace || result.error) {
|
||||
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function projectNameFromPath(repoPath: string): string {
|
||||
return repoPath.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? repoPath;
|
||||
}
|
||||
|
||||
export async function openHomeWithProject(page: Page, repoPath: string): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: projectNameFromPath(repoPath) })
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
function createWorkspaceButton(page: Page, repoPath: string) {
|
||||
return page.getByRole("button", {
|
||||
name: `Create a new workspace for ${projectNameFromPath(repoPath)}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function revealWorkspaceButton(page: Page, repoPath: string): Promise<void> {
|
||||
await page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: projectNameFromPath(repoPath) })
|
||||
.first()
|
||||
.hover();
|
||||
}
|
||||
|
||||
export async function createWorkspaceFromSidebar(page: Page, repoPath: string): Promise<void> {
|
||||
const button = createWorkspaceButton(page, repoPath);
|
||||
await revealWorkspaceButton(page, repoPath);
|
||||
await expect(button).toBeVisible({ timeout: 30_000 });
|
||||
await expect(button).toBeEnabled({ timeout: 30_000 });
|
||||
await button.click();
|
||||
await expect(page).toHaveURL(/\/new\?/, { timeout: 30_000 });
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCurrentWorkspaceIdFromRoute(page: Page): Promise<string> {
|
||||
await expect
|
||||
.poll(
|
||||
() => parseHostWorkspaceRouteFromPathname(new URL(page.url()).pathname)?.workspaceId ?? null,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeNull();
|
||||
|
||||
const workspaceId =
|
||||
parseHostWorkspaceRouteFromPathname(new URL(page.url()).pathname)?.workspaceId ?? null;
|
||||
if (!workspaceId) {
|
||||
throw new Error(`Expected a workspace route but found ${page.url()}`);
|
||||
}
|
||||
|
||||
return workspaceId;
|
||||
}
|
||||
|
||||
function workspaceSetupDialog(page: Page) {
|
||||
return page.getByTestId("workspace-setup-dialog");
|
||||
}
|
||||
|
||||
export async function createChatAgentFromWorkspaceSetup(
|
||||
page: Page,
|
||||
input: { message: string },
|
||||
): Promise<void> {
|
||||
const messageInput = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await expect(messageInput).toBeVisible({ timeout: 15_000 });
|
||||
await messageInput.fill(input.message);
|
||||
await messageInput.press("Enter");
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated The new workspace screen no longer has a standalone terminal button.
|
||||
* Use the daemon API to create a workspace, then open a terminal from the launcher.
|
||||
*/
|
||||
export async function createStandaloneTerminalFromWorkspaceSetup(page: Page): Promise<void> {
|
||||
await workspaceSetupDialog(page)
|
||||
.getByRole("button", { name: /^Terminal Create the workspace/i })
|
||||
.click();
|
||||
}
|
||||
|
||||
export async function waitForWorkspaceSetupDialogToClose(
|
||||
page: Page,
|
||||
timeoutMs = 45_000,
|
||||
): Promise<void> {
|
||||
const dialog = workspaceSetupDialog(page);
|
||||
|
||||
try {
|
||||
await expect(dialog).toHaveCount(0, { timeout: timeoutMs });
|
||||
} catch (error) {
|
||||
const dialogText = (await dialog.textContent().catch(() => null))?.replace(/\s+/g, " ").trim();
|
||||
throw new Error(
|
||||
dialogText
|
||||
? `Workspace setup dialog stayed open. Visible text: ${dialogText}`
|
||||
: `Workspace setup dialog did not close within ${timeoutMs}ms`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectSetupPanel(page: Page): Promise<void> {
|
||||
// If the setup panel is already visible (auto-opened), we're done.
|
||||
const panel = page.getByTestId("workspace-setup-panel");
|
||||
if (await panel.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
// Otherwise open it manually via workspace header actions menu.
|
||||
// Use the specific testID to avoid matching the sidebar kebab which shares
|
||||
// the same "Workspace actions" accessibility label.
|
||||
const actionsButton = page.getByTestId("workspace-header-menu-trigger");
|
||||
await expect(actionsButton).toBeVisible({ timeout: 10_000 });
|
||||
await actionsButton.click();
|
||||
const showSetup = page.getByTestId("workspace-header-show-setup");
|
||||
await expect(showSetup).toBeVisible({ timeout: 5_000 });
|
||||
await showSetup.click();
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectSetupStatus(
|
||||
page: Page,
|
||||
status: "Running" | "Completed" | "Failed",
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-setup-status")).toContainText(status, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectSetupLogContains(page: Page, text: string): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-setup-log")).toContainText(text, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectNoSetupMessage(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByText("No setup commands ran for this workspace.", { exact: true }),
|
||||
).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createWorkspaceThroughDaemon(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
input: { cwd: string; worktreeSlug: string },
|
||||
): Promise<{ id: string; name: string }> {
|
||||
const result = await client.createPaseoWorktree(input);
|
||||
if (!result.workspace || result.error) {
|
||||
throw new Error(result.error ?? `Failed to create workspace for ${input.cwd}`);
|
||||
}
|
||||
return {
|
||||
id: String(result.workspace.id),
|
||||
name: result.workspace.name,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findWorktreeWorkspaceForProject(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
repoPath: string,
|
||||
): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
projectRootPath: string;
|
||||
workspaceDirectory: string;
|
||||
}> {
|
||||
const payload = await client.fetchWorkspaces();
|
||||
const normalizedRepoPath = realpathSync(repoPath);
|
||||
const workspace =
|
||||
payload.entries.find(
|
||||
(entry) =>
|
||||
entry.projectRootPath === normalizedRepoPath &&
|
||||
entry.workspaceDirectory !== normalizedRepoPath,
|
||||
) ?? null;
|
||||
if (!workspace) {
|
||||
throw new Error(`Failed to find created worktree workspace for ${repoPath}`);
|
||||
}
|
||||
return {
|
||||
id: String(workspace.id),
|
||||
name: workspace.name,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchWorkspaceById(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceDirectory: string;
|
||||
projectRootPath: string;
|
||||
}> {
|
||||
const payload = await client.fetchWorkspaces();
|
||||
const workspace = payload.entries.find((entry) => String(entry.id) === workspaceId) ?? null;
|
||||
if (!workspace) {
|
||||
throw new Error(`Workspace not found: ${workspaceId}`);
|
||||
}
|
||||
return workspace;
|
||||
}
|
||||
|
||||
export async function waitForWorkspaceSetupProgress(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
predicate: (payload: WorkspaceSetupProgressPayload) => boolean,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<WorkspaceSetupProgressPayload> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error(`Timed out waiting for workspace_setup_progress after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
const unsubscribe = client.subscribeRawMessages((message) => {
|
||||
if (message.type !== "workspace_setup_progress") {
|
||||
return;
|
||||
}
|
||||
if (!predicate(message.payload)) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve(message.payload);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -13,49 +13,15 @@ export async function getWorkspaceTabTestIds(page: Page): Promise<string[]> {
|
||||
return ids;
|
||||
}
|
||||
|
||||
function visibleTestId(page: Page, testId: string) {
|
||||
return page.getByTestId(testId).filter({ visible: true });
|
||||
}
|
||||
|
||||
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
||||
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
|
||||
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(visibleTestId(page, "workspace-new-agent-tab").first()).toBeVisible({
|
||||
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise<string[]> {
|
||||
const tabs = page.locator('[data-testid^="workspace-tab-agent_"]').filter({ visible: true });
|
||||
const count = await tabs.count();
|
||||
const ids: string[] = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const testId = await tabs.nth(index).getAttribute("data-testid");
|
||||
if (testId && !ids.includes(testId)) {
|
||||
ids.push(testId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function expectOnlyWorkspaceAgentTabsVisible(
|
||||
page: Page,
|
||||
expectedAgentIds: string[],
|
||||
): Promise<void> {
|
||||
const expected = new Set(expectedAgentIds.map((id) => `workspace-tab-agent_${id}`));
|
||||
const visible = await getVisibleWorkspaceAgentTabIds(page);
|
||||
const unexpected = visible.filter((id) => !expected.has(id));
|
||||
|
||||
expect(unexpected).toEqual([]);
|
||||
expect(visible.length).toBe(expected.size);
|
||||
for (const expectedId of expectedAgentIds) {
|
||||
await expect(visibleTestId(page, `workspace-tab-agent_${expectedId}`).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void> {
|
||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
if (!(await toggle.isVisible().catch(() => false))) {
|
||||
|
||||
@@ -6,17 +6,6 @@ export async function openNewAgentComposer(page: Page): Promise<void> {
|
||||
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 {
|
||||
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
@@ -46,29 +35,6 @@ function workspaceRowLocator(page: Page, serverId: string, workspacePath: string
|
||||
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: {
|
||||
page: Page;
|
||||
serverId: string;
|
||||
@@ -84,27 +50,12 @@ 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(
|
||||
page: Page,
|
||||
input: { title: string; subtitle: string },
|
||||
): Promise<void> {
|
||||
const titleLocator = page.getByTestId("workspace-header-title").filter({ visible: true });
|
||||
const subtitleLocator = page.getByTestId("workspace-header-subtitle").filter({ visible: true });
|
||||
const titleLocator = page.getByTestId("workspace-header-title");
|
||||
const subtitleLocator = page.getByTestId("workspace-header-subtitle");
|
||||
|
||||
await expect(titleLocator.first()).toHaveText(input.title, {
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtemp, writeFile, rm, mkdir, realpath } from "node:fs/promises";
|
||||
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
type TempRepo = {
|
||||
path: string;
|
||||
branchHeads: Record<string, string>;
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const createTempGitRepo = async (
|
||||
prefix = "paseo-e2e-",
|
||||
options?: {
|
||||
withRemote?: boolean;
|
||||
paseoConfig?: Record<string, unknown>;
|
||||
files?: Array<{ path: string; content: string }>;
|
||||
branches?: string[];
|
||||
},
|
||||
options?: { withRemote?: boolean },
|
||||
): Promise<TempRepo> => {
|
||||
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
||||
// Resolve symlinks (macOS: /tmp → /private/tmp) so paths match the daemon's resolved paths.
|
||||
const tempRoot = process.platform === "win32" ? tmpdir() : await realpath("/tmp");
|
||||
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
|
||||
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
|
||||
const withRemote = options?.withRemote ?? false;
|
||||
|
||||
@@ -29,92 +22,22 @@ export const createTempGitRepo = async (
|
||||
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
|
||||
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" });
|
||||
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" });
|
||||
|
||||
const branchHeads: Record<string, string> = {};
|
||||
const branches = Array.from(new Set(options?.branches ?? []));
|
||||
for (const branch of branches) {
|
||||
if (branch !== "main") {
|
||||
execSync(`git checkout -b ${JSON.stringify(branch)} main`, {
|
||||
cwd: repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
const markerPath = `.paseo-e2e-${branch.replace(/[^a-zA-Z0-9._-]/g, "-")}.txt`;
|
||||
await writeFile(path.join(repoPath, markerPath), `branch ${branch}\n`);
|
||||
execSync(`git add ${JSON.stringify(markerPath)}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync(`git commit -m ${JSON.stringify(`Add ${branch} marker`)}`, {
|
||||
cwd: repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
branchHeads[branch] = execSync(`git rev-parse ${JSON.stringify(branch)}`, {
|
||||
cwd: repoPath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
execSync("git checkout main", { cwd: repoPath, stdio: "ignore" });
|
||||
}
|
||||
|
||||
if (withRemote) {
|
||||
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
||||
const remoteDir = path.join(repoPath, "remote.git");
|
||||
await mkdir(remoteDir, { recursive: true });
|
||||
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
|
||||
execSync("git push -u origin main", { cwd: repoPath, stdio: "ignore" });
|
||||
}
|
||||
|
||||
return {
|
||||
path: repoPath,
|
||||
branchHeads,
|
||||
cleanup: async () => {
|
||||
await rm(repoPath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export async function readWorktreeBranchInfo({ worktreePath }: { worktreePath: string }): Promise<{
|
||||
currentBranch: string;
|
||||
hasAncestor: (ref: string) => boolean;
|
||||
}> {
|
||||
const currentBranch = execSync("git branch --show-current", {
|
||||
cwd: worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
return {
|
||||
currentBranch,
|
||||
hasAncestor: (ref: string) => {
|
||||
try {
|
||||
execSync(`git merge-base --is-ancestor ${JSON.stringify(ref)} HEAD`, {
|
||||
cwd: worktreePath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
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,8 +1,5 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
@@ -10,21 +7,12 @@ import {
|
||||
clickNewWorkspaceButton,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
createWorktreeViaDaemon,
|
||||
expectComposerGithubAttachmentPill,
|
||||
expectStartingRefPickerTriggerPr,
|
||||
openNewWorkspaceComposer,
|
||||
openStartingRefPicker,
|
||||
openProjectViaDaemon,
|
||||
selectBranchInPicker,
|
||||
selectGitHubPrInPicker,
|
||||
} from "./helpers/new-workspace";
|
||||
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
expectWorkspaceHeader,
|
||||
switchWorkspaceViaSidebar,
|
||||
waitForSidebarHydration,
|
||||
waitForWorkspaceInSidebar,
|
||||
workspaceLabelFromPath,
|
||||
} from "./helpers/workspace-ui";
|
||||
|
||||
@@ -33,7 +21,7 @@ test.describe("New workspace flow", () => {
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
const createdWorktreeIds = new Set<string>();
|
||||
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
client = await connectNewWorkspaceDaemonClient();
|
||||
@@ -68,17 +56,11 @@ test.describe("New workspace flow", () => {
|
||||
localWorkspaceIds.add(firstWorkspace.workspaceId);
|
||||
localWorkspaceIds.add(secondWorkspace.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: firstWorkspace.workspaceId,
|
||||
});
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: firstWorkspace.workspaceName,
|
||||
subtitle: firstWorkspace.projectDisplayName,
|
||||
subtitle: workspaceLabelFromPath(firstRepo.path),
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
@@ -86,13 +68,9 @@ test.describe("New workspace flow", () => {
|
||||
serverId,
|
||||
targetWorkspacePath: secondWorkspace.workspaceId,
|
||||
});
|
||||
await waitForWorkspaceInSidebar(page, {
|
||||
serverId,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: secondWorkspace.workspaceName,
|
||||
subtitle: secondWorkspace.projectDisplayName,
|
||||
subtitle: workspaceLabelFromPath(secondRepo.path),
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
@@ -102,7 +80,7 @@ test.describe("New workspace flow", () => {
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: firstWorkspace.workspaceName,
|
||||
subtitle: firstWorkspace.projectDisplayName,
|
||||
subtitle: workspaceLabelFromPath(firstRepo.path),
|
||||
});
|
||||
} finally {
|
||||
await secondRepo.cleanup();
|
||||
@@ -110,87 +88,6 @@ 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 ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -205,17 +102,11 @@ test.describe("New workspace flow", () => {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
});
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, openedProject.workspaceId));
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, openedProject.workspaceId));
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: openedProject.workspaceName,
|
||||
subtitle: openedProject.projectDisplayName,
|
||||
subtitle: workspaceLabelFromPath(tempRepo.path),
|
||||
});
|
||||
|
||||
await clickNewWorkspaceButton(page, {
|
||||
@@ -248,18 +139,10 @@ test.describe("New workspace flow", () => {
|
||||
subtitle: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
const activeWorkspaceDeckEntry = page
|
||||
.getByTestId(`workspace-deck-entry-${serverId}:${createdWorkspace.workspaceId}`)
|
||||
.filter({ visible: true });
|
||||
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const agentTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]');
|
||||
await expect(agentTabs).toHaveCount(1, { timeout: 30_000 });
|
||||
|
||||
// Workspace setup may auto-open a setup tab that steals focus,
|
||||
// hiding the agent panel (display:none removes it from the
|
||||
// accessibility tree). Click the agent tab to ensure it's active.
|
||||
await agentTabs.first().click();
|
||||
const draftTabs = page.locator('[data-testid^="workspace-tab-"]').filter({
|
||||
has: page.getByText("New Agent", { exact: true }),
|
||||
});
|
||||
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
||||
@@ -267,94 +150,4 @@ test.describe("New workspace flow", () => {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const tempRepo = await createTempGitRepo("new-workspace-ref-", {
|
||||
branches: ["main", "dev"],
|
||||
});
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: openedProject.workspaceName,
|
||||
subtitle: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, "dev");
|
||||
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||
await createButton.click();
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
|
||||
expect(existsSync(createdWorkspace.workspaceId)).toBe(true);
|
||||
|
||||
const branchInfo = await readWorktreeBranchInfo({
|
||||
worktreePath: createdWorkspace.workspaceId,
|
||||
});
|
||||
expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceId));
|
||||
expect(branchInfo.hasAncestor(tempRepo.branchHeads.main)).toBe(true);
|
||||
expect(branchInfo.hasAncestor(tempRepo.branchHeads.dev)).toBe(true);
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
|
||||
const tempRepo = await createTempGitRepo("new-workspace-pr-ref-");
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await openStartingRefPicker(page);
|
||||
await selectGitHubPrInPicker(page, 515);
|
||||
|
||||
await expectStartingRefPickerTriggerPr(page, {
|
||||
number: 515,
|
||||
title: "Review selected start ref",
|
||||
headRef: "feature/start-from-pr",
|
||||
});
|
||||
await expectComposerGithubAttachmentPill(page, {
|
||||
number: 515,
|
||||
title: "Review selected start ref",
|
||||
});
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
|
||||
|
||||
function getSeededServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function getSeededDaemonPort(): string {
|
||||
const port = process.env.E2E_DAEMON_PORT;
|
||||
if (!port) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
async function openHostPage(page: Page, serverId: string) {
|
||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectHostLabelHeader(page: Page) {
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(TEST_HOST_LABEL);
|
||||
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||
}
|
||||
|
||||
test.describe("Settings host page", () => {
|
||||
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getSeededServerId();
|
||||
const port = getSeededDaemonPort();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openHostPage(page, serverId);
|
||||
|
||||
// Label renders in the detail header with a pencil edit affordance; the input is hidden until edit.
|
||||
await expectHostLabelHeader(page);
|
||||
|
||||
// Connections is its own section with a "Connections" heading and the seeded endpoint row.
|
||||
const connectionsCard = page.getByTestId("host-page-connections-card");
|
||||
await expect(connectionsCard).toBeVisible();
|
||||
await expect(page.getByText("Connections", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
connectionsCard.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
|
||||
).toBeVisible();
|
||||
|
||||
const injectMcpCard = page.getByTestId("host-page-inject-mcp-card");
|
||||
await expect(injectMcpCard).toBeVisible();
|
||||
await expect(injectMcpCard.getByRole("button", { name: "On", exact: true })).toBeVisible();
|
||||
await expect(injectMcpCard.getByRole("button", { name: "Off", exact: true })).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
|
||||
});
|
||||
|
||||
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
|
||||
const serverId = getSeededServerId();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openHostPage(page, serverId);
|
||||
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("host-page-label-edit-button").click();
|
||||
|
||||
await expect(page.getByTestId("host-page-label-input")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveValue(TEST_HOST_LABEL);
|
||||
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
|
||||
});
|
||||
|
||||
test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getSeededServerId();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openHostPage(page, serverId);
|
||||
|
||||
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
|
||||
// Pair-device now lives behind a row that only the local host sees
|
||||
// (gated by useIsLocalDaemon); the seeded host is remote, so it must
|
||||
// not appear. The daemon-lifecycle card is still local-host only.
|
||||
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
|
||||
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("settings sidebar does not expose retired top-level sections", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
|
||||
|
||||
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getSeededServerId();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
|
||||
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
await expectHostLabelHeader(page);
|
||||
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
||||
});
|
||||
|
||||
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
|
||||
const serverId = getSeededServerId();
|
||||
|
||||
// Simulate the Electron desktop bridge so `useIsLocalDaemon` resolves the
|
||||
// seeded host to the local daemon. `manageBuiltInDaemon: false` bypasses
|
||||
// the desktop bootstrap flow so only the sidebar's status query runs.
|
||||
await page.addInitScript((localServerId) => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ theme: "auto", manageBuiltInDaemon: false, sendBehavior: "interrupt" }),
|
||||
);
|
||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
||||
platform: "darwin",
|
||||
invoke: async (command: string) => {
|
||||
if (command === "desktop_daemon_status") {
|
||||
return {
|
||||
serverId: localServerId,
|
||||
status: "running",
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: null,
|
||||
home: "",
|
||||
version: null,
|
||||
desktopManaged: true,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getPendingOpenProject: async () => null,
|
||||
events: { on: async () => () => undefined },
|
||||
};
|
||||
}, serverId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const hostEntries = sidebar.locator('[data-testid^="settings-host-entry-"]');
|
||||
await expect(hostEntries.first()).toHaveAttribute(
|
||||
"data-testid",
|
||||
`settings-host-entry-${serverId}`,
|
||||
);
|
||||
|
||||
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
|
||||
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
|
||||
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function clickSidebarSection(page: Page, label: string) {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible();
|
||||
await sidebar.getByRole("button", { name: label, exact: true }).click();
|
||||
}
|
||||
|
||||
test.describe("Settings sidebar navigation", () => {
|
||||
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
await clickSidebarSection(page, "Diagnostics");
|
||||
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("Diagnostics");
|
||||
|
||||
await clickSidebarSection(page, "About");
|
||||
await expect(page).toHaveURL(/\/settings\/about$/);
|
||||
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("About");
|
||||
|
||||
await clickSidebarSection(page, "General");
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("General");
|
||||
});
|
||||
|
||||
test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||
);
|
||||
});
|
||||
|
||||
test("the + Add host button opens the add-host method modal", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
await page.getByTestId("settings-add-host").click();
|
||||
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Direct connection" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const backRow = page.getByTestId("settings-back-to-workspace");
|
||||
await expect(backRow).toBeVisible();
|
||||
|
||||
await backRow.click();
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Settings — compact master-detail", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
async function openCompactSettingsRoot(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
// Wait for bootstrap to settle on a host route so storeReady is true before
|
||||
// we navigate into the protected settings stack. A direct page.goto("/settings")
|
||||
// on a cold load is eaten by Stack.Protected while bootstrap is still running.
|
||||
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
|
||||
|
||||
// Drive navigation the same way a user would: open the mobile drawer and tap
|
||||
// the Settings footer icon. This preserves the in-app router state instead of
|
||||
// triggering a full reload through Stack.Protected.
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(sidebarSettingsButton).toBeVisible();
|
||||
await sidebarSettingsButton.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectCompactSettingsRootList(page: Page) {
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
|
||||
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
|
||||
}
|
||||
|
||||
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
|
||||
await openCompactSettingsRoot(page);
|
||||
|
||||
// Sidebar rows are present.
|
||||
await expect(
|
||||
page.getByTestId("settings-sidebar").getByRole("button", { name: "General", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("settings-sidebar")
|
||||
.getByRole("button", { name: "Diagnostics", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("settings-sidebar").getByRole("button", { name: "About", exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// Section detail content is NOT rendered at the root.
|
||||
await expectCompactSettingsRootList(page);
|
||||
|
||||
const rootBackButton = page.getByRole("button", { name: "Back", exact: true });
|
||||
await expect(rootBackButton).toBeVisible();
|
||||
await rootBackButton.click();
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
|
||||
await openCompactSettingsRoot(page);
|
||||
|
||||
await page
|
||||
.getByTestId("settings-sidebar")
|
||||
.getByRole("button", { name: "Diagnostics", exact: true })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
||||
// Sidebar is no longer visible — we are on a detail screen.
|
||||
// (Expo Router stack keeps the previous screen in the DOM but hidden; check
|
||||
// only visible instances.)
|
||||
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("back from a section detail returns to the /settings list", async ({ page }) => {
|
||||
await openCompactSettingsRoot(page);
|
||||
|
||||
await page
|
||||
.getByTestId("settings-sidebar")
|
||||
.getByRole("button", { name: "About", exact: true })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/settings\/about$/);
|
||||
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
await expectCompactSettingsRootList(page);
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await openCompactSettingsRoot(page);
|
||||
|
||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||
);
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("back from a host detail returns to the /settings list", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await openCompactSettingsRoot(page);
|
||||
|
||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
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,15 +20,10 @@ const KEYSTROKE_P95_BUDGET_MS = 150;
|
||||
test.describe("Terminal wire performance", () => {
|
||||
let client: TerminalPerfDaemonClient;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
let workspaceId: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("perf-");
|
||||
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 () => {
|
||||
@@ -50,7 +45,7 @@ test.describe("Terminal wire performance", () => {
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
try {
|
||||
await navigateToTerminal(page, { workspaceId, terminalId });
|
||||
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
|
||||
await setupDeterministicPrompt(page);
|
||||
|
||||
const sentinel = `PERF_DONE_${Date.now()}`;
|
||||
@@ -109,7 +104,7 @@ test.describe("Terminal wire performance", () => {
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
try {
|
||||
await navigateToTerminal(page, { workspaceId, terminalId });
|
||||
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
|
||||
await setupDeterministicPrompt(page);
|
||||
|
||||
// Ensure clean prompt state
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,195 +0,0 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,190 +0,0 @@
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
connectArchiveTabDaemonClient,
|
||||
createIdleAgent,
|
||||
expectWorkspaceTabHidden,
|
||||
expectWorkspaceTabVisible,
|
||||
openWorkspaceWithAgents,
|
||||
} from "./helpers/archive-tab";
|
||||
import {
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
getVisibleWorkspaceAgentTabIds,
|
||||
expectOnlyWorkspaceAgentTabsVisible,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "./helpers/workspace-tabs";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
expectWorkspaceHeader,
|
||||
switchWorkspaceViaSidebar,
|
||||
waitForSidebarHydration,
|
||||
} from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Workspace navigation regression", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const workspaceClient = await connectNewWorkspaceDaemonClient();
|
||||
const archiveClient = await connectArchiveTabDaemonClient();
|
||||
const workspaceIds = new Set<string>();
|
||||
const agentIds: string[] = [];
|
||||
const firstRepo = await createTempGitRepo("workspace-nav-reg-a-");
|
||||
const secondRepo = await createTempGitRepo("workspace-nav-reg-b-");
|
||||
|
||||
try {
|
||||
const firstWorkspace = await openProjectViaDaemon(workspaceClient, firstRepo.path);
|
||||
const secondWorkspace = await openProjectViaDaemon(workspaceClient, secondRepo.path);
|
||||
workspaceIds.add(firstWorkspace.workspaceId);
|
||||
workspaceIds.add(secondWorkspace.workspaceId);
|
||||
|
||||
const firstAgent = await createIdleAgent(archiveClient, {
|
||||
cwd: firstRepo.path,
|
||||
title: `workspace-nav-a-${Date.now()}`,
|
||||
});
|
||||
const secondAgent = await createIdleAgent(archiveClient, {
|
||||
cwd: secondRepo.path,
|
||||
title: `workspace-nav-b-${Date.now()}`,
|
||||
});
|
||||
agentIds.push(firstAgent.id, secondAgent.id);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: firstWorkspace.workspaceId,
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
});
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
selected: false,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: firstWorkspace.workspaceName,
|
||||
subtitle: firstWorkspace.projectDisplayName,
|
||||
});
|
||||
await expectWorkspaceTabVisible(page, firstAgent.id);
|
||||
await expectWorkspaceTabHidden(page, secondAgent.id);
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
|
||||
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
||||
`workspace-tab-agent_${firstAgent.id}`,
|
||||
]);
|
||||
|
||||
const firstDeckEntry = page.getByTestId(
|
||||
`workspace-deck-entry-${serverId}:${firstWorkspace.workspaceId}`,
|
||||
);
|
||||
const secondDeckEntry = page.getByTestId(
|
||||
`workspace-deck-entry-${serverId}:${secondWorkspace.workspaceId}`,
|
||||
);
|
||||
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: secondWorkspace.workspaceId,
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
});
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
selected: false,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: secondWorkspace.workspaceName,
|
||||
subtitle: secondWorkspace.projectDisplayName,
|
||||
});
|
||||
await expectWorkspaceTabVisible(page, secondAgent.id);
|
||||
await expectWorkspaceTabHidden(page, firstAgent.id);
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
|
||||
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
||||
`workspace-tab-agent_${secondAgent.id}`,
|
||||
]);
|
||||
await expect(firstDeckEntry).toBeAttached();
|
||||
await expect(firstDeckEntry).toBeHidden();
|
||||
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: firstWorkspace.workspaceId,
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||
await expect(secondDeckEntry).toBeAttached();
|
||||
await expect(secondDeckEntry).toBeHidden();
|
||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: firstWorkspace.workspaceName,
|
||||
subtitle: firstWorkspace.projectDisplayName,
|
||||
});
|
||||
await expectWorkspaceTabVisible(page, firstAgent.id);
|
||||
await expectWorkspaceTabHidden(page, secondAgent.id);
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
|
||||
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
||||
`workspace-tab-agent_${firstAgent.id}`,
|
||||
]);
|
||||
} finally {
|
||||
for (const agentId of agentIds) {
|
||||
await archiveAgentFromDaemon(archiveClient, agentId).catch(() => undefined);
|
||||
}
|
||||
for (const workspaceId of workspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
|
||||
}
|
||||
await archiveClient.close().catch(() => undefined);
|
||||
await workspaceClient.close().catch(() => undefined);
|
||||
await secondRepo.cleanup();
|
||||
await firstRepo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,345 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { waitForTerminalContent } from "./helpers/terminal-perf";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
createWorkspaceThroughDaemon,
|
||||
expectSetupPanel,
|
||||
openHomeWithProject,
|
||||
seedProjectForWorkspaceSetup,
|
||||
waitForWorkspaceSetupProgress,
|
||||
} from "./helpers/workspace-setup";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
/** Click the sidebar row for a workspace (by ID) and wait for navigation. */
|
||||
async function navigateToWorkspaceViaSidebar(
|
||||
page: import("@playwright/test").Page,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
const row = page.getByTestId(testId);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("Workspace setup streaming", () => {
|
||||
test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-open-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: [
|
||||
"sh -c 'echo starting setup; for i in $(seq 1 30); do echo tick $i; sleep 1; done; echo setup complete'",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: `setup-open-${Date.now()}`,
|
||||
});
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
|
||||
await expectSetupPanel(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("runs setup through the sidebar and leaves the workspace usable", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-ui-flow-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: [
|
||||
"sh -c 'echo starting setup; sleep 1; echo loading dependencies; sleep 1; echo setup complete'",
|
||||
],
|
||||
},
|
||||
},
|
||||
files: [{ path: "src/index.ts", content: "export const ready = true;\n" }],
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
|
||||
// Wait for setup completion via daemon (setup snapshots are per-session,
|
||||
// so the browser session won't receive progress events).
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: `setup-ui-flow-${Date.now()}`,
|
||||
});
|
||||
await completed;
|
||||
|
||||
// Navigate to workspace and verify it's usable
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first().click();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
if ((await explorerToggle.getAttribute("aria-label")) === "Open explorer") {
|
||||
await explorerToggle.click();
|
||||
}
|
||||
await expect(explorerToggle).toHaveAttribute("aria-label", "Close explorer", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByTestId("explorer-tab-files").click();
|
||||
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("README.md", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText("src", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("streams running and completed setup snapshots for a successful setup", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-success-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["sh -c 'echo starting setup; sleep 2; echo setup complete'"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const initialRunning = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "running" && payload.detail.log === "",
|
||||
);
|
||||
const runningWithOutput = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "running" && payload.detail.log.includes("starting setup"),
|
||||
);
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
);
|
||||
|
||||
await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "workspace-setup-success",
|
||||
});
|
||||
|
||||
const initialPayload = await initialRunning;
|
||||
const runningPayload = await runningWithOutput;
|
||||
const completedPayload = await completed;
|
||||
|
||||
expect(initialPayload.detail.log).toBe("");
|
||||
expect(runningPayload.detail.log).toContain("starting setup");
|
||||
expect(completedPayload.detail.log).toContain("setup complete");
|
||||
expect(completedPayload.error).toBeNull();
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("streams a failed setup snapshot when setup fails", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-failure-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["sh -c 'echo starting setup; sleep 2; echo setup failed 1>&2; exit 1'"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const failed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
|
||||
);
|
||||
|
||||
await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "workspace-setup-failure",
|
||||
});
|
||||
|
||||
const failedPayload = await failed;
|
||||
expect(failedPayload.detail.log).toContain("starting setup");
|
||||
expect(failedPayload.detail.log).toContain("setup failed");
|
||||
expect(failedPayload.error).toMatch(/failed/i);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("emits a completed empty snapshot when no setup commands exist", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-none-");
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" &&
|
||||
payload.detail.commands.length === 0 &&
|
||||
payload.detail.log === "",
|
||||
);
|
||||
|
||||
await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "workspace-setup-none",
|
||||
});
|
||||
|
||||
const completedPayload = await completed;
|
||||
expect(completedPayload.error).toBeNull();
|
||||
expect(completedPayload.detail.commands).toEqual([]);
|
||||
expect(completedPayload.detail.log).toBe("");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("launches script terminals after setup completes", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-svc-ui-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"],
|
||||
},
|
||||
scripts: {
|
||||
web: {
|
||||
command:
|
||||
"node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, () => console.log('listening on ' + s.address().port))\"",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
|
||||
// Wait for setup completion via daemon (setup snapshots are per-session)
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: `setup-svc-${Date.now()}`,
|
||||
});
|
||||
await completed;
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
// Wait for the script terminal tab to appear in the tabs bar.
|
||||
// The tab title shows the command, not the script name.
|
||||
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
|
||||
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Click the script terminal tab
|
||||
await terminalTab.click();
|
||||
|
||||
// Verify the terminal surface rendered
|
||||
const terminalSurface = page.getByTestId("terminal-surface").first();
|
||||
await expect(terminalSurface).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Wait for terminal to fully attach (loading overlay gone)
|
||||
await page
|
||||
.locator('[data-testid="terminal-attach-loading"]')
|
||||
.waitFor({ state: "hidden", timeout: 10_000 })
|
||||
.catch(() => {
|
||||
// overlay may never appear if attachment is instant
|
||||
});
|
||||
|
||||
await terminalSurface.click();
|
||||
|
||||
// Verify the terminal output contains "listening on" via xterm buffer API.
|
||||
// The .xterm-rows CSS selector is fragile; reading the buffer directly is reliable.
|
||||
await waitForTerminalContent(page, (text) => text.includes("listening on"), 60_000);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("launches workspace scripts after setup completes", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-scripts-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"],
|
||||
},
|
||||
scripts: {
|
||||
editor: {
|
||||
command: "npm run dev",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
);
|
||||
|
||||
const result = await client.createPaseoWorktree({
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "workspace-setup-scripts",
|
||||
});
|
||||
if (!result.workspace) {
|
||||
throw new Error(result.error ?? "Failed to create workspace");
|
||||
}
|
||||
const workspaceDir = result.workspace.workspaceDirectory;
|
||||
|
||||
await completed;
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const terminals = await client.listTerminals(workspaceDir);
|
||||
return terminals.terminals.find((terminal) => terminal.name === "editor") ?? null;
|
||||
})
|
||||
.toMatchObject({
|
||||
id: expect.any(String),
|
||||
name: "editor",
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,50 +0,0 @@
|
||||
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,15 +8,7 @@ appId: sh.paseo
|
||||
|
||||
- eraseText
|
||||
- inputText: ${PAIRING_OFFER_URL}
|
||||
|
||||
# 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"
|
||||
- tapOn: "Pair"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "New agent"
|
||||
|
||||
@@ -8,7 +8,7 @@ appId: sh.paseo
|
||||
- eraseText
|
||||
- inputText: ${PAIRING_OFFER_URL}
|
||||
- tapOn:
|
||||
id: "pair-link-submit"
|
||||
point: "75%,36%"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "New agent"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.60-beta.1",
|
||||
"version": "0.1.55-rc.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "APP_VARIANT=development expo start",
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"eas-build-post-install": "npm run build:workspace-deps",
|
||||
@@ -31,9 +31,9 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.60-beta.1",
|
||||
"@getpaseo/highlight": "0.1.60-beta.1",
|
||||
"@getpaseo/server": "0.1.60-beta.1",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.55-rc.1",
|
||||
"@getpaseo/highlight": "0.1.55-rc.1",
|
||||
"@getpaseo/server": "0.1.55-rc.1",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -79,11 +79,10 @@
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-updates": "~29.0.12",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"lucide-react-native": "^0.546.0",
|
||||
"mnemonic-id": "^3.2.7",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react": "19.1.4",
|
||||
"react-dom": "19.1.4",
|
||||
"react-native": "^0.81.5",
|
||||
"react-native-css": "^3.0.1",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
@@ -103,13 +102,11 @@
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-webview": "^13.16.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"use-sync-external-store": "^1.6.0",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "~19.2.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"eas-cli": "^16.24.1",
|
||||
|
||||
@@ -51,17 +51,6 @@
|
||||
[contenteditable='true'] {
|
||||
-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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { PortalProvider } from "@gorhom/portal";
|
||||
import { VoiceProvider } from "@/contexts/voice-context";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
@@ -61,14 +62,8 @@ import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||
import { resolveActiveHost } from "@/utils/active-host";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||
import {
|
||||
WEB_NOTIFICATION_CLICK_EVENT,
|
||||
type WebNotificationClickDetail,
|
||||
@@ -84,12 +79,8 @@ import {
|
||||
parseServerIdFromPathname,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseWorkspaceOpenIntent,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
} from "@/utils/host-routes";
|
||||
import {
|
||||
addBrowserActiveWorkspaceLocationListener,
|
||||
syncNavigationActiveWorkspace,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
|
||||
polyfillCrypto();
|
||||
@@ -394,11 +385,9 @@ function AppContainer({
|
||||
const { theme } = useUnistyles();
|
||||
const daemons = useHosts();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
||||
const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
|
||||
const openDesktopAgentList = usePanelStore((state) => state.openDesktopAgentList);
|
||||
const closeDesktopAgentList = usePanelStore((state) => state.closeDesktopAgentList);
|
||||
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||
const toggleBothSidebars = usePanelStore((state) => state.toggleBothSidebars);
|
||||
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
|
||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
@@ -412,32 +401,6 @@ function AppContainer({
|
||||
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
|
||||
const pathname = usePathname();
|
||||
const activeServerId = useMemo(
|
||||
() => resolveActiveHost({ hosts: daemons, pathname })?.serverId ?? null,
|
||||
[daemons, pathname],
|
||||
);
|
||||
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
|
||||
const toggleDesktopSidebars = useCallback(() => {
|
||||
const { desktop } = usePanelStore.getState();
|
||||
toggleDesktopSidebarsWithCheckoutIntent({
|
||||
isAgentListOpen: desktop.agentListOpen,
|
||||
isFileExplorerOpen: desktop.fileExplorerOpen,
|
||||
openAgentList: openDesktopAgentList,
|
||||
closeAgentList: closeDesktopAgentList,
|
||||
closeFileExplorer: closeDesktopFileExplorer,
|
||||
toggleFocusedFileExplorer: () =>
|
||||
keyboardActionDispatcher.dispatch({
|
||||
id: "sidebar.toggle.right",
|
||||
scope: "sidebar",
|
||||
}),
|
||||
});
|
||||
}, [closeDesktopAgentList, closeDesktopFileExplorer, openDesktopAgentList]);
|
||||
// TODO: stop matching pathname here as a branch. `chromeEnabled` should not
|
||||
// conflate workspace/project-specific chrome (sidebar, mobile gesture) with
|
||||
// global concerns like keyboard shortcuts. Split those out so settings (and
|
||||
// other non-workspace routes) don't need a special-case to keep shortcuts alive.
|
||||
const keyboardShortcutsEnabled = chromeEnabled || pathname.startsWith("/settings");
|
||||
|
||||
useEffect(() => {
|
||||
const bp = UnistylesRuntime.breakpoint;
|
||||
@@ -469,16 +432,16 @@ function AppContainer({
|
||||
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
|
||||
|
||||
useKeyboardShortcuts({
|
||||
enabled: keyboardShortcutsEnabled,
|
||||
enabled: chromeEnabled,
|
||||
isMobile: isCompactLayout,
|
||||
toggleAgentList,
|
||||
toggleBothSidebars: toggleDesktopSidebars,
|
||||
selectedAgentId,
|
||||
toggleFileExplorer,
|
||||
toggleBothSidebars,
|
||||
toggleFocusMode,
|
||||
cycleTheme,
|
||||
});
|
||||
|
||||
useActiveWorktreeNewAction();
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
|
||||
[theme.colors.surface0],
|
||||
@@ -497,11 +460,6 @@ function AppContainer({
|
||||
<UpdateBanner />
|
||||
<CommandCenter />
|
||||
<ProjectPickerModal />
|
||||
<WorkspaceShortcutTargetsSubscriber
|
||||
enabled={keyboardShortcutsEnabled}
|
||||
serverId={activeServerId}
|
||||
/>
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
</View>
|
||||
);
|
||||
@@ -521,7 +479,7 @@ function MobileGestureWrapper({
|
||||
chromeEnabled: boolean;
|
||||
}) {
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||
const horizontalScroll = useHorizontalScrollOptional();
|
||||
const {
|
||||
translateX,
|
||||
@@ -538,8 +496,8 @@ function MobileGestureWrapper({
|
||||
|
||||
const handleGestureOpen = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
showMobileAgentList();
|
||||
}, [showMobileAgentList, gestureAnimatingRef]);
|
||||
openAgentList();
|
||||
}, [openAgentList, gestureAnimatingRef]);
|
||||
|
||||
const openGesture = useMemo(
|
||||
() =>
|
||||
@@ -830,6 +788,7 @@ function FaviconStatusSync() {
|
||||
function RootStack() {
|
||||
const storeReady = useStoreReady();
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
@@ -842,25 +801,23 @@ function RootStack() {
|
||||
>
|
||||
<Stack.Protected guard={storeReady}>
|
||||
<Stack.Screen name="welcome" />
|
||||
<Stack.Screen name="settings/index" />
|
||||
<Stack.Screen name="settings/[section]" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack.Protected>
|
||||
{/*
|
||||
Do not add getId or dangerouslySingular back to the workspace route.
|
||||
Expo Router maps dangerouslySingular to React Navigation getId, and
|
||||
getId repeatedly breaks Android native-stack/Fabric by reordering an
|
||||
already-mounted workspace screen. Keep workspace identity/retention
|
||||
outside this route-level native-stack API.
|
||||
*/}
|
||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/workspace/[workspaceId]"
|
||||
getId={({ params }) => {
|
||||
const serverId = getRouteParamValue(params?.serverId);
|
||||
const workspaceId = getRouteParamValue(params?.workspaceId);
|
||||
return serverId && workspaceId ? `${serverId}:${workspaceId}` : undefined;
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/sessions" />
|
||||
<Stack.Screen name="h/[serverId]/open-project" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -870,7 +827,6 @@ function NavigationActiveWorkspaceObserver() {
|
||||
|
||||
useEffect(() => {
|
||||
syncNavigationActiveWorkspace(navigationRef);
|
||||
const unsubscribeBrowserLocation = addBrowserActiveWorkspaceLocationListener();
|
||||
const unsubscribeState = navigationRef.addListener("state", () => {
|
||||
syncNavigationActiveWorkspace(navigationRef);
|
||||
});
|
||||
@@ -878,7 +834,6 @@ function NavigationActiveWorkspaceObserver() {
|
||||
syncNavigationActiveWorkspace(navigationRef);
|
||||
});
|
||||
return () => {
|
||||
unsubscribeBrowserLocation();
|
||||
unsubscribeState();
|
||||
unsubscribeReady();
|
||||
};
|
||||
@@ -897,21 +852,23 @@ export default function RootLayout() {
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<QueryProvider>
|
||||
<HostRuntimeBootstrapProvider>
|
||||
<PushNotificationRouter />
|
||||
<ToastProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<HostRuntimeBootstrapProvider>
|
||||
<PushNotificationRouter />
|
||||
<ProvidersWrapper>
|
||||
<SidebarAnimationProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<OpenProjectListener />
|
||||
<AppWithSidebar>
|
||||
<RootStack />
|
||||
</AppWithSidebar>
|
||||
<ToastProvider>
|
||||
<OpenProjectListener />
|
||||
<AppWithSidebar>
|
||||
<RootStack />
|
||||
</AppWithSidebar>
|
||||
</ToastProvider>
|
||||
</HorizontalScrollProvider>
|
||||
</SidebarAnimationProvider>
|
||||
</ProvidersWrapper>
|
||||
</ToastProvider>
|
||||
</HostRuntimeBootstrapProvider>
|
||||
</HostRuntimeBootstrapProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</QueryProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
|
||||
@@ -2,10 +2,8 @@ import { useEffect, useRef } from "react";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
export default function HostAgentReadyRoute() {
|
||||
@@ -33,10 +31,6 @@ function HostAgentReadyRouteContent() {
|
||||
}
|
||||
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
|
||||
});
|
||||
const hasHydratedWorkspaces = useSessionStore((state) =>
|
||||
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
||||
);
|
||||
const resolvedWorkspaceId = useResolveWorkspaceIdByCwd(serverId, agentCwd);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
@@ -48,17 +42,18 @@ function HostAgentReadyRouteContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedWorkspaceId) {
|
||||
const normalizedCwd = agentCwd?.trim();
|
||||
if (normalizedCwd) {
|
||||
redirectedRef.current = true;
|
||||
router.replace(
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId: resolvedWorkspaceId,
|
||||
workspaceId: normalizedCwd,
|
||||
target: { kind: "agent", agentId },
|
||||
}) as any,
|
||||
);
|
||||
}
|
||||
}, [agentId, resolvedWorkspaceId, router, serverId]);
|
||||
}, [agentCwd, agentId, router, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
@@ -67,14 +62,14 @@ function HostAgentReadyRouteContent() {
|
||||
if (!serverId || !agentId) {
|
||||
return;
|
||||
}
|
||||
if (agentCwd?.trim() && !hasHydratedWorkspaces) {
|
||||
if (agentCwd?.trim()) {
|
||||
return;
|
||||
}
|
||||
if (!client || !isConnected) {
|
||||
redirectedRef.current = true;
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
}
|
||||
}, [agentCwd, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
|
||||
}, [agentCwd, agentId, client, isConnected, router, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
@@ -92,20 +87,12 @@ function HostAgentReadyRouteContent() {
|
||||
return;
|
||||
}
|
||||
const cwd = result?.agent?.cwd?.trim();
|
||||
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: workspaces?.values(),
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
if (!workspaceId && !hasHydratedWorkspaces) {
|
||||
return;
|
||||
}
|
||||
redirectedRef.current = true;
|
||||
if (workspaceId) {
|
||||
if (cwd) {
|
||||
router.replace(
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceId: cwd,
|
||||
target: { kind: "agent", agentId },
|
||||
}) as any,
|
||||
);
|
||||
@@ -124,7 +111,7 @@ function HostAgentReadyRouteContent() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
|
||||
}, [agentId, client, isConnected, router, serverId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,86 @@
|
||||
import { Redirect, useLocalSearchParams } from "expo-router";
|
||||
import { buildHostOpenProjectRoute } from "@/utils/host-routes";
|
||||
import { useEffect } from "react";
|
||||
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
||||
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() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<HostIndexRouteContent />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function HostIndexRouteContent() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
if (!serverId) return null;
|
||||
return <Redirect href={buildHostOpenProjectRoute(serverId)} />;
|
||||
const { isLoading: preferencesLoading } = useFormPreferences();
|
||||
const sessionAgents = useSessionStore((state) =>
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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,15 +1,10 @@
|
||||
import { Redirect, useLocalSearchParams } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
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();
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
|
||||
export default function HostSettingsRoute() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<Redirect href={href} />
|
||||
<SettingsScreen />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { StyleSheet, View } from "react-native";
|
||||
import {
|
||||
useGlobalSearchParams,
|
||||
useLocalSearchParams,
|
||||
useRouter,
|
||||
useRootNavigationState,
|
||||
} from "expo-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import {
|
||||
activateNavigationWorkspaceSelection,
|
||||
type ActiveWorkspaceSelection,
|
||||
useNavigationActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
import {
|
||||
buildHostWorkspaceRoute,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
parseWorkspaceOpenIntent,
|
||||
type WorkspaceOpenIntent,
|
||||
@@ -45,35 +32,9 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
|
||||
if (openIntent.kind === "file") {
|
||||
return { kind: "file", path: openIntent.path };
|
||||
}
|
||||
if (openIntent.kind === "setup") {
|
||||
return { kind: "setup", workspaceId: openIntent.workspaceId };
|
||||
}
|
||||
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() {
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
@@ -83,8 +44,6 @@ export default function HostWorkspaceLayout() {
|
||||
}
|
||||
|
||||
function HostWorkspaceLayoutContent() {
|
||||
const navigation = useNavigation();
|
||||
const router = useRouter();
|
||||
const rootNavigationState = useRootNavigationState();
|
||||
const consumedIntentRef = useRef<string | null>(null);
|
||||
const [intentConsumed, setIntentConsumed] = useState(false);
|
||||
@@ -101,23 +60,6 @@ function HostWorkspaceLayoutContent() {
|
||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||
: "";
|
||||
const openValue = getParamValue(globalParams.open);
|
||||
const routeWorkspaceSelection = useMemo(
|
||||
() =>
|
||||
serverId && workspaceId
|
||||
? {
|
||||
serverId,
|
||||
workspaceId,
|
||||
}
|
||||
: null,
|
||||
[serverId, workspaceId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!routeWorkspaceSelection) {
|
||||
return;
|
||||
}
|
||||
activateNavigationWorkspaceSelection(routeWorkspaceSelection);
|
||||
}, [routeWorkspaceSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openValue) {
|
||||
@@ -129,13 +71,6 @@ function HostWorkspaceLayoutContent() {
|
||||
|
||||
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
||||
if (consumedIntentRef.current === consumptionKey) {
|
||||
clearConsumedOpenIntent({
|
||||
navigation,
|
||||
router,
|
||||
serverId,
|
||||
workspaceId,
|
||||
});
|
||||
setIntentConsumed(true);
|
||||
return;
|
||||
}
|
||||
consumedIntentRef.current = consumptionKey;
|
||||
@@ -153,87 +88,26 @@ function HostWorkspaceLayoutContent() {
|
||||
// Expo Router's replace ignores query-param-only changes (findDivergentState
|
||||
// skips search params). Strip ?open from the browser URL directly so the
|
||||
// address bar reflects the clean workspace route.
|
||||
clearConsumedOpenIntent({
|
||||
navigation,
|
||||
router,
|
||||
serverId,
|
||||
workspaceId,
|
||||
});
|
||||
if (isWeb && typeof window !== "undefined") {
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.has("open")) {
|
||||
url.searchParams.delete("open");
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
}
|
||||
}
|
||||
|
||||
setIntentConsumed(true);
|
||||
}, [navigation, openValue, rootNavigationState?.key, router, serverId, workspaceId]);
|
||||
}, [openValue, rootNavigationState?.key, serverId, workspaceId]);
|
||||
|
||||
if (openValue && !intentConsumed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <WorkspaceDeck fallbackSelection={routeWorkspaceSelection} />;
|
||||
}
|
||||
|
||||
function areWorkspaceSelectionsEqual(
|
||||
left: ActiveWorkspaceSelection | null,
|
||||
right: ActiveWorkspaceSelection | null,
|
||||
): boolean {
|
||||
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
|
||||
}
|
||||
|
||||
function WorkspaceDeck({
|
||||
fallbackSelection,
|
||||
}: {
|
||||
fallbackSelection: ActiveWorkspaceSelection | null;
|
||||
}) {
|
||||
const activeSelection = useNavigationActiveWorkspaceSelection() ?? fallbackSelection;
|
||||
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
||||
activeSelection ? [activeSelection] : [],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSelection) {
|
||||
return;
|
||||
}
|
||||
setMountedSelections((current) => {
|
||||
if (current.some((selection) => areWorkspaceSelectionsEqual(selection, activeSelection))) {
|
||||
return current;
|
||||
}
|
||||
return [...current, activeSelection];
|
||||
});
|
||||
}, [activeSelection]);
|
||||
|
||||
if (!activeSelection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.deck}>
|
||||
{mountedSelections.map((selection) => {
|
||||
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
|
||||
return (
|
||||
<View
|
||||
key={`${selection.serverId}:${selection.workspaceId}`}
|
||||
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
|
||||
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
||||
>
|
||||
<WorkspaceScreen
|
||||
serverId={selection.serverId}
|
||||
workspaceId={selection.workspaceId}
|
||||
isRouteFocused={isActive}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<WorkspaceScreen
|
||||
key={`${serverId}:${workspaceId}`}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
deck: {
|
||||
flex: 1,
|
||||
},
|
||||
activeDeckEntry: {
|
||||
flex: 1,
|
||||
},
|
||||
inactiveDeckEntry: {
|
||||
display: "none",
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
|
||||
const WELCOME_ROUTE = "/welcome";
|
||||
|
||||
@@ -33,8 +32,6 @@ function useAnyOnlineHostServerId(serverIds: string[]): string | null {
|
||||
);
|
||||
}
|
||||
|
||||
const isDesktop = shouldUseDesktopDaemon();
|
||||
|
||||
export default function Index() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -55,5 +52,5 @@ export default function Index() {
|
||||
router.replace(targetRoute);
|
||||
}, [anyOnlineServerId, pathname, router, storeReady]);
|
||||
|
||||
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
||||
return <StartupSplashScreen bootstrapState={bootstrapState} />;
|
||||
}
|
||||
|
||||
@@ -5,19 +5,35 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { CameraView, useCameraPermissions } from "expo-camera";
|
||||
import type { BarcodeScanningResult } from "expo-camera";
|
||||
import { useHostMutations } from "@/runtime/host-runtime";
|
||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
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: {
|
||||
flex: 1,
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
@@ -124,32 +140,63 @@ export default function PairScanScreen() {
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams<{
|
||||
source?: string;
|
||||
sourceServerId?: string;
|
||||
targetServerId?: string;
|
||||
}>();
|
||||
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 [permission, requestPermission] = useCameraPermissions();
|
||||
const [isPairing, setIsPairing] = useState(false);
|
||||
const lastScannedRef = useRef<string | null>(null);
|
||||
|
||||
const navigateToPairedHost = useCallback(
|
||||
const returnToSource = useCallback(
|
||||
(serverId: string) => {
|
||||
if (source === "onboarding") {
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
return;
|
||||
}
|
||||
router.replace(buildSettingsHostRoute(serverId));
|
||||
if (source === "editHost" && targetServerId) {
|
||||
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],
|
||||
[router, source, sourceServerId, targetServerId],
|
||||
);
|
||||
|
||||
const closeToSource = useCallback(() => {
|
||||
if (source === "editHost" && targetServerId) {
|
||||
const settingsServerId = sourceServerId ?? targetServerId;
|
||||
router.replace({
|
||||
pathname: buildHostSettingsRoute(settingsServerId),
|
||||
params: { editHost: targetServerId },
|
||||
} as any);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
router.back();
|
||||
} catch {
|
||||
if (sourceServerId) {
|
||||
router.replace(buildHostSettingsRoute(sourceServerId));
|
||||
return;
|
||||
}
|
||||
router.replace("/" as any);
|
||||
}
|
||||
}, [router]);
|
||||
}, [router, source, sourceServerId, targetServerId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWeb) return;
|
||||
@@ -173,6 +220,15 @@ export default function PairScanScreen() {
|
||||
const offerPayload = decodeOfferFragmentPayload(encoded);
|
||||
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(
|
||||
{
|
||||
id: "probe",
|
||||
@@ -186,7 +242,7 @@ export default function PairScanScreen() {
|
||||
|
||||
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
|
||||
|
||||
navigateToPairedHost(profile.serverId);
|
||||
returnToSource(profile.serverId);
|
||||
} catch (error) {
|
||||
lastScannedRef.current = null;
|
||||
const message = error instanceof Error ? error.message : "Unable to pair host";
|
||||
@@ -195,13 +251,18 @@ export default function PairScanScreen() {
|
||||
setIsPairing(false);
|
||||
}
|
||||
},
|
||||
[isPairing, navigateToPairedHost, upsertDaemonFromOfferUrl],
|
||||
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
|
||||
);
|
||||
|
||||
if (isWeb) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="Scan QR" onBack={() => router.back()} />
|
||||
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
||||
<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.permissionCard}>
|
||||
<Text style={styles.permissionTitle}>Not available on web</Text>
|
||||
@@ -221,7 +282,12 @@ export default function PairScanScreen() {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="Scan QR" onBack={closeToSource} />
|
||||
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
|
||||
<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] }]}>
|
||||
{!granted ? (
|
||||
|
||||
27
packages/app/src/app/settings.tsx
Normal file
27
packages/app/src/app/settings.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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 }} />;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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,5 +1,3 @@
|
||||
import type { GitHubSearchItem } from "@server/shared/messages";
|
||||
|
||||
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
|
||||
|
||||
export interface AttachmentMetadata {
|
||||
@@ -17,11 +15,6 @@ export interface AttachmentMetadata {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export type ComposerAttachment =
|
||||
| { kind: "image"; metadata: AttachmentMetadata }
|
||||
| { kind: "github_issue"; item: GitHubSearchItem }
|
||||
| { kind: "github_pr"; item: GitHubSearchItem };
|
||||
|
||||
export type AttachmentDataSource =
|
||||
| { kind: "blob"; blob: Blob }
|
||||
| { kind: "data_url"; dataUrl: string }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef, useCallback, useEffect, useMemo } from "react";
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
|
||||
@@ -7,49 +7,15 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetTextInput,
|
||||
type BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { X } from "lucide-react-native";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
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) => ({
|
||||
desktopOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
@@ -70,34 +36,22 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.surface2,
|
||||
overflow: "hidden",
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingVertical: theme.spacing[4],
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.surface2,
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
headerTitleGroup: {
|
||||
flex: 1,
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
marginLeft: theme.spacing[3],
|
||||
marginRight: theme.spacing[2],
|
||||
},
|
||||
closeButton: {
|
||||
padding: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -112,33 +66,23 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[4],
|
||||
flexGrow: 1,
|
||||
},
|
||||
bottomSheetHandle: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
bottomSheetHeader: {
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingTop: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[3],
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
alignItems: "center",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.surface2,
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
bottomSheetContent: {
|
||||
padding: theme.spacing[6],
|
||||
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) {
|
||||
@@ -159,43 +103,52 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
|
||||
export interface AdaptiveModalSheetProps {
|
||||
title: string;
|
||||
/** Optional content rendered below the title in the header area. */
|
||||
subtitle?: ReactNode;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
headerActions?: ReactNode;
|
||||
snapPoints?: string[];
|
||||
stackBehavior?: "push" | "switch" | "replace";
|
||||
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({
|
||||
title,
|
||||
subtitle,
|
||||
visible,
|
||||
onClose,
|
||||
children,
|
||||
headerActions,
|
||||
snapPoints,
|
||||
stackBehavior,
|
||||
testID,
|
||||
desktopMaxWidth,
|
||||
onFilesDropped,
|
||||
scrollable = true,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const titleColor = theme.colors.foreground;
|
||||
const sheetRef = useRef<BottomSheetModal>(null);
|
||||
const dismissingForVisibilityRef = useRef(false);
|
||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
|
||||
visible,
|
||||
isEnabled: isMobile,
|
||||
onClose,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
if (visible) {
|
||||
dismissingForVisibilityRef.current = false;
|
||||
sheetRef.current?.present();
|
||||
} else {
|
||||
dismissingForVisibilityRef.current = true;
|
||||
sheetRef.current?.dismiss();
|
||||
}
|
||||
}, [visible, isMobile]);
|
||||
|
||||
const handleSheetChange = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
if (dismissingForVisibilityRef.current) {
|
||||
dismissingForVisibilityRef.current = false;
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
@@ -204,14 +157,9 @@ export function AdaptiveModalSheet({
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || isMobile || !visible) return;
|
||||
return pushEscHandler(onClose);
|
||||
}, [visible, isMobile, onClose]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
<BottomSheetModal
|
||||
ref={sheetRef}
|
||||
snapPoints={resolvedSnapPoints}
|
||||
index={0}
|
||||
@@ -219,67 +167,28 @@ export function AdaptiveModalSheet({
|
||||
onChange={handleSheetChange}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
stackBehavior={stackBehavior}
|
||||
backgroundComponent={SheetBackground}
|
||||
handleIndicatorStyle={{ backgroundColor: theme.colors.surface2 }}
|
||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
accessible={false}
|
||||
>
|
||||
<View style={styles.bottomSheetHeader} testID={testID}>
|
||||
<View style={styles.headerTitleGroup}>
|
||||
<Text key={titleColor} style={[styles.title, { color: titleColor }]} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle}
|
||||
</View>
|
||||
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
{scrollable ? (
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.bottomSheetContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</BottomSheetScrollView>
|
||||
) : (
|
||||
<View style={styles.bottomSheetStaticContent}>{children}</View>
|
||||
)}
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
const cardInner = (
|
||||
<>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerTitleGroup}>
|
||||
<Text key={titleColor} style={[styles.title, { color: titleColor }]} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle}
|
||||
</View>
|
||||
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
|
||||
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.desktopContent}
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.bottomSheetContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={styles.desktopStaticContent}>{children}</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
const desktopContent = (
|
||||
<View style={styles.desktopOverlay} testID={testID}>
|
||||
@@ -288,12 +197,21 @@ export function AdaptiveModalSheet({
|
||||
style={{ ...StyleSheet.absoluteFillObject }}
|
||||
onPress={onClose}
|
||||
/>
|
||||
<View style={[styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }]}>
|
||||
{onFilesDropped ? (
|
||||
<FileDropZone onFilesDropped={onFilesDropped}>{cardInner}</FileDropZone>
|
||||
) : (
|
||||
cardInner
|
||||
)}
|
||||
<View style={styles.desktopCard}>
|
||||
<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>
|
||||
<ScrollView
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.desktopContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -70,9 +70,7 @@ export function AddHostMethodModal({
|
||||
<Pressable
|
||||
style={styles.option}
|
||||
onPress={handleDirect}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Direct connection"
|
||||
testID="add-host-method-direct"
|
||||
>
|
||||
<Link2 size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
@@ -82,12 +80,7 @@ export function AddHostMethodModal({
|
||||
</Pressable>
|
||||
|
||||
{isNative ? (
|
||||
<Pressable
|
||||
style={styles.option}
|
||||
onPress={handleScan}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Scan QR code"
|
||||
>
|
||||
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
|
||||
<QrCode size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
<Text style={styles.optionText}>Scan QR code</Text>
|
||||
@@ -99,9 +92,7 @@ export function AddHostMethodModal({
|
||||
<Pressable
|
||||
style={styles.option}
|
||||
onPress={handlePaste}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Paste pairing link"
|
||||
testID="add-host-method-pair-link"
|
||||
>
|
||||
<ClipboardPaste size={18} color={theme.colors.foreground} />
|
||||
<View style={styles.optionBody}>
|
||||
|
||||
@@ -132,6 +132,7 @@ function buildConnectionFailureCopy(
|
||||
export interface AddHostModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
targetServerId?: string;
|
||||
onCancel?: () => void;
|
||||
onSaved?: (result: {
|
||||
profile: HostProfile;
|
||||
@@ -141,41 +142,42 @@ export interface AddHostModalProps {
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
|
||||
export function AddHostModal({
|
||||
visible,
|
||||
onClose,
|
||||
onCancel,
|
||||
onSaved,
|
||||
targetServerId,
|
||||
}: AddHostModalProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const daemons = useHosts();
|
||||
const { upsertDirectConnection } = useHostMutations();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
|
||||
const hostInputRef = useRef<TextInput>(null);
|
||||
const endpointRawRef = useRef("");
|
||||
|
||||
const [endpointRaw, setEndpointRaw] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
endpointRawRef.current = "";
|
||||
hostInputRef.current?.clear();
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
clearInput();
|
||||
setEndpointRaw("");
|
||||
setErrorMessage("");
|
||||
onClose();
|
||||
}, [isSaving, clearInput, onClose]);
|
||||
}, [isSaving, onClose]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
clearInput();
|
||||
setEndpointRaw("");
|
||||
setErrorMessage("");
|
||||
(onCancel ?? onClose)();
|
||||
}, [isSaving, clearInput, onCancel, onClose]);
|
||||
}, [isSaving, onCancel, onClose]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving) return;
|
||||
|
||||
const raw = endpointRawRef.current.trim();
|
||||
const raw = endpointRaw.trim();
|
||||
if (!raw) {
|
||||
setErrorMessage("Host is required");
|
||||
return;
|
||||
@@ -204,6 +206,15 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
endpoint,
|
||||
});
|
||||
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 profile = await upsertDirectConnection({
|
||||
serverId,
|
||||
@@ -229,7 +240,16 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDirectConnection]);
|
||||
}, [
|
||||
daemons,
|
||||
endpointRaw,
|
||||
handleClose,
|
||||
isMobile,
|
||||
isSaving,
|
||||
onSaved,
|
||||
targetServerId,
|
||||
upsertDirectConnection,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
@@ -244,12 +264,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
<Text style={styles.label}>Host</Text>
|
||||
<AdaptiveTextInput
|
||||
ref={hostInputRef}
|
||||
testID="direct-host-input"
|
||||
nativeID="direct-host-input"
|
||||
accessibilityLabel="direct-host-input"
|
||||
onChangeText={(next) => {
|
||||
endpointRawRef.current = next;
|
||||
}}
|
||||
value={endpointRaw}
|
||||
onChangeText={setEndpointRaw}
|
||||
placeholder="hostname:port"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
@@ -273,7 +289,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
onPress={() => void handleSave()}
|
||||
disabled={isSaving}
|
||||
leftIcon={<Link2 size={16} color={theme.colors.palette.white} />}
|
||||
testID="direct-host-submit"
|
||||
>
|
||||
{isSaving ? "Connecting..." : "Connect"}
|
||||
</Button>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { View, Text, Pressable, TextInput, ActivityIndicator } from "react-nativ
|
||||
import type { StyleProp, ViewStyle, TextProps } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackgroundProps,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
ShieldAlert,
|
||||
ShieldOff,
|
||||
} from "lucide-react-native";
|
||||
import { theme as defaultTheme } from "@/styles/theme";
|
||||
import type {
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
@@ -29,10 +31,6 @@ import type {
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
|
||||
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { baseColors } from "@/styles/theme";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
@@ -88,8 +86,6 @@ export function DropdownField({
|
||||
renderTrigger,
|
||||
testID,
|
||||
}: DropdownFieldProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
if (renderTrigger) {
|
||||
return (
|
||||
<>
|
||||
@@ -119,7 +115,7 @@ export function DropdownField({
|
||||
<Text style={value ? styles.dropdownValue : styles.dropdownPlaceholder} numberOfLines={1}>
|
||||
{value || placeholder}
|
||||
</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
|
||||
@@ -155,8 +151,6 @@ export function SelectField({
|
||||
valueEllipsizeMode,
|
||||
testID,
|
||||
}: SelectFieldProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const getWebKey = useCallback((event: unknown): string | null => {
|
||||
if (!event || typeof event !== "object") return null;
|
||||
const eventWithNative = event as { nativeEvent?: unknown; key?: unknown };
|
||||
@@ -216,7 +210,7 @@ export function SelectField({
|
||||
{value || placeholder || "Select..."}
|
||||
</Text>
|
||||
</View>
|
||||
<ChevronRight size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||
<ChevronRight size={defaultTheme.iconSize.lg} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
|
||||
@@ -235,21 +229,7 @@ interface DropdownSheetProps {
|
||||
}
|
||||
|
||||
function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
style,
|
||||
{
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />;
|
||||
}
|
||||
|
||||
export function DropdownSheet({
|
||||
@@ -258,18 +238,31 @@ export function DropdownSheet({
|
||||
onClose,
|
||||
children,
|
||||
}: DropdownSheetProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const titleColor = theme.colors.foreground;
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
|
||||
visible,
|
||||
onClose,
|
||||
});
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
bottomSheetRef.current?.dismiss();
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
bottomSheetRef.current?.present();
|
||||
} else {
|
||||
bottomSheetRef.current?.dismiss();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleSheetChange = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||
@@ -278,8 +271,8 @@ export function DropdownSheet({
|
||||
);
|
||||
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetRef}
|
||||
snapPoints={snapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
@@ -287,14 +280,12 @@ export function DropdownSheet({
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={DropdownSheetBackground}
|
||||
handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
|
||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text key={titleColor} style={[styles.dropdownSheetTitle, { color: titleColor }]}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={styles.dropdownSheetTitle}>{title}</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Close sheet"
|
||||
@@ -302,7 +293,7 @@ export function DropdownSheet({
|
||||
hitSlop={10}
|
||||
testID="dropdown-sheet-close"
|
||||
>
|
||||
<X size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<BottomSheetScrollView
|
||||
@@ -312,7 +303,7 @@ export function DropdownSheet({
|
||||
>
|
||||
{children}
|
||||
</BottomSheetScrollView>
|
||||
</IsolatedBottomSheetModal>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -422,8 +413,6 @@ export function FormSelectTrigger({
|
||||
valueEllipsizeMode,
|
||||
testID,
|
||||
}: CompactSelectFieldProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const getWebKey = useCallback((event: unknown): string | null => {
|
||||
if (!event || typeof event !== "object") return null;
|
||||
const eventWithNative = event as { nativeEvent?: unknown; key?: unknown };
|
||||
@@ -480,7 +469,7 @@ export function FormSelectTrigger({
|
||||
<View style={styles.compactSelectValueContainer}>
|
||||
{showLabel ? <Text style={styles.compactSelectLabel}>{label}</Text> : null}
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
<ActivityIndicator size="small" color={defaultTheme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<Text
|
||||
style={hasConcreteValue ? styles.compactSelectValue : styles.compactSelectPlaceholder}
|
||||
@@ -491,7 +480,7 @@ export function FormSelectTrigger({
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -529,8 +518,6 @@ export function AgentConfigRow({
|
||||
onSelectThinkingOption,
|
||||
disabled,
|
||||
}: AgentConfigRowProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const providerOptions: ComboSelectOption[] = useMemo(
|
||||
() =>
|
||||
providerDefinitions.map((def) => ({
|
||||
@@ -589,7 +576,7 @@ export function AgentConfigRow({
|
||||
placeholder={providerOptions.length > 0 ? "Select..." : "No providers available"}
|
||||
disabled={disabled || providerOptions.length === 0}
|
||||
onSelect={onSelectProvider}
|
||||
icon={<Bot size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
|
||||
icon={<Bot size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
|
||||
showLabel={false}
|
||||
testID="draft-provider-select"
|
||||
/>
|
||||
@@ -604,7 +591,9 @@ export function AgentConfigRow({
|
||||
disabled={disabled}
|
||||
isLoading={isModelLoading}
|
||||
onSelect={onSelectModel}
|
||||
icon={<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
|
||||
icon={
|
||||
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
}
|
||||
showLabel={false}
|
||||
testID="draft-model-select"
|
||||
/>
|
||||
@@ -618,7 +607,7 @@ export function AgentConfigRow({
|
||||
placeholder="Default"
|
||||
disabled={disabled || modeOptions.length === 0}
|
||||
onSelect={onSelectMode}
|
||||
icon={<ModeIcon size={theme.iconSize.md} color={modeIconColor} />}
|
||||
icon={<ModeIcon size={defaultTheme.iconSize.md} color={modeIconColor} />}
|
||||
showLabel={false}
|
||||
testID="draft-mode-select"
|
||||
/>
|
||||
@@ -633,7 +622,9 @@ export function AgentConfigRow({
|
||||
placeholder="Select..."
|
||||
disabled={disabled}
|
||||
onSelect={onSelectThinkingOption}
|
||||
icon={<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
|
||||
icon={
|
||||
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
}
|
||||
showLabel={false}
|
||||
/>
|
||||
</View>
|
||||
@@ -978,8 +969,6 @@ export function GitOptionsSection({
|
||||
attachWorktreeError,
|
||||
onSelectWorktreePath,
|
||||
}: GitOptionsSectionProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const isLoading = status === "loading";
|
||||
const isCreateMode = worktreeMode === "create";
|
||||
const isAttachMode = worktreeMode === "attach";
|
||||
@@ -1114,7 +1103,7 @@ export function GitOptionsSection({
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
placeholder="branch name"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
placeholderTextColor={defaultTheme.colors.foregroundMuted}
|
||||
onSubmitEditing={handleConfirmEdit}
|
||||
/>
|
||||
<Pressable
|
||||
@@ -1122,16 +1111,19 @@ export function GitOptionsSection({
|
||||
hitSlop={8}
|
||||
style={styles.baseBranchIconButton}
|
||||
>
|
||||
<Check size={theme.iconSize.md} color={theme.colors.palette.green[500]} />
|
||||
<Check
|
||||
size={defaultTheme.iconSize.md}
|
||||
color={defaultTheme.colors.palette.green[500]}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
|
||||
<X size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable onPress={handleStartEdit} style={styles.baseBranchValueRow}>
|
||||
<Text style={styles.baseBranchValue}>{displayBranch}</Text>
|
||||
<Pencil size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Pencil size={defaultTheme.iconSize.sm} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
@@ -1179,6 +1171,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
bottomSheetBackground: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
bottomSheetHandle: {
|
||||
backgroundColor: theme.colors.palette.zinc[600],
|
||||
},
|
||||
bottomSheetHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -1220,6 +1220,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
dropdownSheetTitle: {
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
textAlign: "center",
|
||||
},
|
||||
dropdownSheetScrollContent: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveStatusControlMode } from "./composer.status-controls";
|
||||
import { resolveStatusControlMode } from "./agent-input-area.status-controls";
|
||||
|
||||
describe("resolveStatusControlMode", () => {
|
||||
it("uses ready mode when no controlled status controls are provided", () => {
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,20 +24,19 @@ describe("submitAgentInput", () => {
|
||||
});
|
||||
const clearDraft = vi.fn();
|
||||
const setUserInput = vi.fn();
|
||||
const setAttachments = vi.fn();
|
||||
const setSelectedImages = vi.fn();
|
||||
const setSendError = vi.fn();
|
||||
const setIsProcessing = vi.fn();
|
||||
|
||||
const submitPromise = submitAgentInput({
|
||||
message: " hello world ",
|
||||
attachments: [],
|
||||
isAgentRunning: false,
|
||||
canSubmit: true,
|
||||
queueMessage,
|
||||
submitMessage,
|
||||
clearDraft,
|
||||
setUserInput,
|
||||
setAttachments,
|
||||
setSelectedImages,
|
||||
setSendError,
|
||||
setIsProcessing,
|
||||
});
|
||||
@@ -45,55 +44,10 @@ describe("submitAgentInput", () => {
|
||||
expect(queueMessage).not.toHaveBeenCalled();
|
||||
expect(submitMessage).toHaveBeenCalledWith({
|
||||
message: "hello world",
|
||||
attachments: [],
|
||||
imageAttachments: undefined,
|
||||
});
|
||||
expect(setUserInput).toHaveBeenCalledWith("");
|
||||
expect(setAttachments).toHaveBeenCalledWith([]);
|
||||
expect(setSendError).toHaveBeenCalledWith(null);
|
||||
expect(setIsProcessing).toHaveBeenCalledWith(true);
|
||||
expect(clearDraft).not.toHaveBeenCalled();
|
||||
|
||||
deferred.resolve();
|
||||
|
||||
await expect(submitPromise).resolves.toBe("submitted");
|
||||
expect(clearDraft).toHaveBeenCalledWith("sent");
|
||||
});
|
||||
|
||||
it("preserves the composer before an in-flight submit resolves when requested", async () => {
|
||||
const deferred = createDeferredPromise<void>();
|
||||
const attachments = [{ id: "img-1" }];
|
||||
const queueMessage = vi.fn();
|
||||
const submitMessage = vi.fn(async () => {
|
||||
await deferred.promise;
|
||||
});
|
||||
const clearDraft = vi.fn();
|
||||
const setUserInput = vi.fn();
|
||||
const setAttachments = vi.fn();
|
||||
const setSendError = vi.fn();
|
||||
const setIsProcessing = vi.fn();
|
||||
|
||||
const submitPromise = submitAgentInput({
|
||||
message: " keep me ",
|
||||
attachments,
|
||||
submitBehavior: "preserve-and-lock",
|
||||
isAgentRunning: false,
|
||||
canSubmit: true,
|
||||
queueMessage,
|
||||
submitMessage,
|
||||
clearDraft,
|
||||
setUserInput,
|
||||
setAttachments,
|
||||
setSendError,
|
||||
setIsProcessing,
|
||||
});
|
||||
|
||||
expect(queueMessage).not.toHaveBeenCalled();
|
||||
expect(submitMessage).toHaveBeenCalledWith({
|
||||
message: "keep me",
|
||||
attachments,
|
||||
});
|
||||
expect(setUserInput).not.toHaveBeenCalled();
|
||||
expect(setAttachments).not.toHaveBeenCalled();
|
||||
expect(setSelectedImages).toHaveBeenCalledWith([]);
|
||||
expect(setSendError).toHaveBeenCalledWith(null);
|
||||
expect(setIsProcessing).toHaveBeenCalledWith(true);
|
||||
expect(clearDraft).not.toHaveBeenCalled();
|
||||
@@ -109,21 +63,21 @@ describe("submitAgentInput", () => {
|
||||
const submitMessage = vi.fn();
|
||||
const clearDraft = vi.fn();
|
||||
const setUserInput = vi.fn();
|
||||
const setAttachments = vi.fn();
|
||||
const setSelectedImages = vi.fn();
|
||||
const setSendError = vi.fn();
|
||||
const setIsProcessing = vi.fn();
|
||||
|
||||
await expect(
|
||||
submitAgentInput({
|
||||
message: " queued message ",
|
||||
attachments: [{ id: "img-1" }],
|
||||
imageAttachments: [{ id: "img-1" }],
|
||||
isAgentRunning: true,
|
||||
canSubmit: true,
|
||||
queueMessage,
|
||||
submitMessage,
|
||||
clearDraft,
|
||||
setUserInput,
|
||||
setAttachments,
|
||||
setSelectedImages,
|
||||
setSendError,
|
||||
setIsProcessing,
|
||||
}),
|
||||
@@ -131,11 +85,11 @@ describe("submitAgentInput", () => {
|
||||
|
||||
expect(queueMessage).toHaveBeenCalledWith({
|
||||
message: "queued message",
|
||||
attachments: [{ id: "img-1" }],
|
||||
imageAttachments: [{ id: "img-1" }],
|
||||
});
|
||||
expect(submitMessage).not.toHaveBeenCalled();
|
||||
expect(setUserInput).toHaveBeenCalledWith("");
|
||||
expect(setAttachments).toHaveBeenCalledWith([]);
|
||||
expect(setSelectedImages).toHaveBeenCalledWith([]);
|
||||
expect(setSendError).not.toHaveBeenCalled();
|
||||
expect(setIsProcessing).not.toHaveBeenCalled();
|
||||
expect(clearDraft).not.toHaveBeenCalled();
|
||||
@@ -149,23 +103,23 @@ describe("submitAgentInput", () => {
|
||||
});
|
||||
const clearDraft = vi.fn();
|
||||
const setUserInput = vi.fn();
|
||||
const setAttachments = vi.fn();
|
||||
const setSelectedImages = vi.fn();
|
||||
const setSendError = vi.fn();
|
||||
const setIsProcessing = vi.fn();
|
||||
const onSubmitError = vi.fn();
|
||||
const attachments = [{ id: "img-1" }];
|
||||
const imageAttachments = [{ id: "img-1" }];
|
||||
|
||||
await expect(
|
||||
submitAgentInput({
|
||||
message: " hello world ",
|
||||
attachments,
|
||||
imageAttachments,
|
||||
isAgentRunning: false,
|
||||
canSubmit: true,
|
||||
queueMessage,
|
||||
submitMessage,
|
||||
clearDraft,
|
||||
setUserInput,
|
||||
setAttachments,
|
||||
setSelectedImages,
|
||||
setSendError,
|
||||
setIsProcessing,
|
||||
onSubmitError,
|
||||
@@ -175,46 +129,12 @@ describe("submitAgentInput", () => {
|
||||
expect(onSubmitError).toHaveBeenCalledWith(submitError);
|
||||
expect(setUserInput).toHaveBeenNthCalledWith(1, "");
|
||||
expect(setUserInput).toHaveBeenNthCalledWith(2, "hello world");
|
||||
expect(setAttachments).toHaveBeenNthCalledWith(1, []);
|
||||
expect(setAttachments).toHaveBeenNthCalledWith(2, attachments);
|
||||
expect(setSelectedImages).toHaveBeenNthCalledWith(1, []);
|
||||
expect(setSelectedImages).toHaveBeenNthCalledWith(2, imageAttachments);
|
||||
expect(setSendError).toHaveBeenNthCalledWith(1, null);
|
||||
expect(setSendError).toHaveBeenNthCalledWith(2, "No host selected");
|
||||
expect(setIsProcessing).toHaveBeenNthCalledWith(1, true);
|
||||
expect(setIsProcessing).toHaveBeenNthCalledWith(2, false);
|
||||
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,37 +1,28 @@
|
||||
export type AgentInputSubmitResult = "noop" | "queued" | "submitted" | "failed";
|
||||
|
||||
export interface AgentInputSubmitActionInput<TAttachment> {
|
||||
export interface AgentInputSubmitActionInput<TImage> {
|
||||
message: string;
|
||||
attachments: TAttachment[];
|
||||
hasExternalContent?: boolean;
|
||||
allowEmptySubmit?: boolean;
|
||||
submitBehavior?: "clear" | "preserve-and-lock";
|
||||
imageAttachments?: TImage[];
|
||||
forceSend?: boolean;
|
||||
isAgentRunning: boolean;
|
||||
canSubmit: boolean;
|
||||
queueMessage: (input: { message: string; attachments: TAttachment[] }) => void;
|
||||
submitMessage: (input: { message: string; attachments: TAttachment[] }) => Promise<void>;
|
||||
queueMessage: (input: { message: string; imageAttachments?: TImage[] }) => void;
|
||||
submitMessage: (input: { message: string; imageAttachments?: TImage[] }) => Promise<void>;
|
||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||
setUserInput: (text: string) => void;
|
||||
setAttachments: (attachments: TAttachment[]) => void;
|
||||
setSelectedImages: (images: TImage[]) => void;
|
||||
setSendError: (message: string | null) => void;
|
||||
setIsProcessing: (isProcessing: boolean) => void;
|
||||
onSubmitError?: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export async function submitAgentInput<TAttachment>(
|
||||
input: AgentInputSubmitActionInput<TAttachment>,
|
||||
export async function submitAgentInput<TImage>(
|
||||
input: AgentInputSubmitActionInput<TImage>,
|
||||
): Promise<AgentInputSubmitResult> {
|
||||
const trimmedMessage = input.message.trim();
|
||||
const attachments = input.attachments;
|
||||
const shouldClearOnSubmit = input.submitBehavior !== "preserve-and-lock";
|
||||
const imageAttachments = input.imageAttachments;
|
||||
|
||||
if (
|
||||
!trimmedMessage &&
|
||||
attachments.length === 0 &&
|
||||
!input.hasExternalContent &&
|
||||
!input.allowEmptySubmit
|
||||
) {
|
||||
if (!trimmedMessage && !imageAttachments?.length) {
|
||||
return "noop";
|
||||
}
|
||||
|
||||
@@ -40,33 +31,27 @@ export async function submitAgentInput<TAttachment>(
|
||||
}
|
||||
|
||||
if (input.isAgentRunning && !input.forceSend) {
|
||||
input.queueMessage({ message: trimmedMessage, attachments });
|
||||
if (shouldClearOnSubmit) {
|
||||
input.setUserInput("");
|
||||
input.setAttachments([]);
|
||||
}
|
||||
input.queueMessage({ message: trimmedMessage, imageAttachments });
|
||||
input.setUserInput("");
|
||||
input.setSelectedImages([]);
|
||||
return "queued";
|
||||
}
|
||||
|
||||
// Clear immediately so optimistic stream updates and composer state stay in sync.
|
||||
if (shouldClearOnSubmit) {
|
||||
input.setUserInput("");
|
||||
input.setAttachments([]);
|
||||
}
|
||||
input.setUserInput("");
|
||||
input.setSelectedImages([]);
|
||||
input.setSendError(null);
|
||||
input.setIsProcessing(true);
|
||||
|
||||
try {
|
||||
await input.submitMessage({ message: trimmedMessage, attachments });
|
||||
await input.submitMessage({ message: trimmedMessage, imageAttachments });
|
||||
input.clearDraft("sent");
|
||||
input.setIsProcessing(false);
|
||||
return "submitted";
|
||||
} catch (error) {
|
||||
input.onSubmitError?.(error);
|
||||
if (shouldClearOnSubmit) {
|
||||
input.setUserInput(trimmedMessage);
|
||||
input.setAttachments(attachments);
|
||||
}
|
||||
input.setUserInput(trimmedMessage);
|
||||
input.setSelectedImages(imageAttachments ?? []);
|
||||
input.setSendError(error instanceof Error ? error.message : "Failed to send message");
|
||||
input.setIsProcessing(false);
|
||||
return "failed";
|
||||
|
||||
@@ -17,9 +17,6 @@ import { shortenPath } from "@/utils/shorten-path";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
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";
|
||||
|
||||
interface AgentListProps {
|
||||
@@ -134,7 +131,6 @@ function SessionRow({
|
||||
const isSelected = selectedAgentId === agentKey;
|
||||
const statusLabel = formatStatusLabel(agent.status);
|
||||
const projectPath = shortenPath(agent.cwd);
|
||||
const ProviderIcon = getProviderIcon(agent.provider);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -150,9 +146,6 @@ function SessionRow({
|
||||
>
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowTitleRow}>
|
||||
<View style={styles.providerIconWrap}>
|
||||
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
<Text
|
||||
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
|
||||
numberOfLines={1}
|
||||
@@ -239,21 +232,12 @@ export function AgentList({
|
||||
|
||||
const serverId = agent.serverId;
|
||||
const agentId = agent.id;
|
||||
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: useSessionStore.getState().sessions[serverId]?.workspaces?.values(),
|
||||
workspaceDirectory: agent.cwd,
|
||||
});
|
||||
|
||||
onAgentSelect?.();
|
||||
|
||||
if (!workspaceId) {
|
||||
router.navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
|
||||
return;
|
||||
}
|
||||
|
||||
const route = prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceId: agent.cwd,
|
||||
target: { kind: "agent", agentId },
|
||||
pin: Boolean(agent.archivedAt),
|
||||
});
|
||||
@@ -263,18 +247,7 @@ export function AgentList({
|
||||
);
|
||||
|
||||
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
|
||||
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);
|
||||
setActionAgent(agent);
|
||||
}, []);
|
||||
|
||||
const handleCloseActionSheet = useCallback(() => {
|
||||
@@ -378,9 +351,7 @@ export function AgentList({
|
||||
>
|
||||
<View style={styles.sheetHandle} />
|
||||
<Text style={styles.sheetTitle}>
|
||||
{isActionDaemonUnavailable
|
||||
? "Host offline"
|
||||
: "This agent is still running. Archiving it will stop the agent."}
|
||||
{isActionDaemonUnavailable ? "Host offline" : "Archive this session?"}
|
||||
</Text>
|
||||
<View style={styles.sheetButtonRow}>
|
||||
<Pressable
|
||||
@@ -464,11 +435,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
providerIconWrap: {
|
||||
width: theme.iconSize.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
rowMetaRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -52,8 +52,6 @@ import {
|
||||
resolveAgentModelSelection,
|
||||
} from "@/components/agent-status-bar.utils";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
|
||||
type StatusOption = {
|
||||
id: string;
|
||||
@@ -92,7 +90,7 @@ type ControlledAgentStatusBarProps = {
|
||||
|
||||
export interface DraftAgentStatusBarProps {
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
selectedProvider: AgentProvider | null;
|
||||
selectedProvider: AgentProvider;
|
||||
onSelectProvider: (provider: AgentProvider) => void;
|
||||
modeOptions: AgentMode[];
|
||||
selectedMode: string;
|
||||
@@ -255,8 +253,7 @@ function ControlledStatusBar({
|
||||
: undefined;
|
||||
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null;
|
||||
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette);
|
||||
const hasSelectedProvider = provider.trim().length > 0;
|
||||
const ProviderIcon = hasSelectedProvider ? getProviderIcon(provider) : null;
|
||||
const ProviderIcon = getProviderIcon(provider);
|
||||
|
||||
const hasAnyControl =
|
||||
Boolean(providerOptions?.length) ||
|
||||
@@ -427,7 +424,12 @@ function ControlledStatusBar({
|
||||
|
||||
{thinkingOptions && thinkingOptions.length > 0 ? (
|
||||
<>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<Tooltip
|
||||
key={`thinking-${openSelector === "thinking" ? "open" : "closed"}`}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile={false}
|
||||
>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
ref={thinkingAnchorRef}
|
||||
@@ -468,7 +470,12 @@ function ControlledStatusBar({
|
||||
|
||||
{modeOptions && modeOptions.length > 0 ? (
|
||||
<>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<Tooltip
|
||||
key={`mode-${openSelector === "mode" ? "open" : "closed"}`}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile={false}
|
||||
>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
ref={modeAnchorRef}
|
||||
@@ -621,9 +628,7 @@ function ControlledStatusBar({
|
||||
accessibilityLabel="Agent preferences"
|
||||
testID="agent-preferences-button"
|
||||
>
|
||||
{ProviderIcon ? (
|
||||
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||
) : null}
|
||||
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.prefsButtonText} numberOfLines={1}>
|
||||
{displayModel}
|
||||
</Text>
|
||||
@@ -633,6 +638,7 @@ function ControlledStatusBar({
|
||||
title="Preferences"
|
||||
visible={prefsOpen}
|
||||
onClose={() => setPrefsOpen(false)}
|
||||
stackBehavior="replace"
|
||||
testID="agent-preferences-sheet"
|
||||
>
|
||||
{canSelectModel ? (
|
||||
@@ -665,12 +671,7 @@ function ControlledStatusBar({
|
||||
pointerEvents="none"
|
||||
testID="agent-preferences-model"
|
||||
>
|
||||
{ProviderIcon ? (
|
||||
<ProviderIcon
|
||||
size={theme.iconSize.md}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
) : null}
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
@@ -872,23 +873,23 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
||||
);
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const toast = useToast();
|
||||
|
||||
const {
|
||||
entries: snapshotEntries,
|
||||
isLoading: snapshotIsLoading,
|
||||
refetchIfStale: refetchSnapshotIfStale,
|
||||
} = useProvidersSnapshot(serverId, agent?.cwd);
|
||||
isFetching: snapshotIsFetching,
|
||||
invalidate: invalidateSnapshot,
|
||||
} = useProvidersSnapshot(serverId);
|
||||
|
||||
const snapshotSelectedEntry = useMemo(() => {
|
||||
const snapshotModels = useMemo(() => {
|
||||
if (!snapshotEntries || !agent?.provider) {
|
||||
return null;
|
||||
}
|
||||
return snapshotEntries.find((e) => e.provider === agent.provider) ?? null;
|
||||
const entry = snapshotEntries.find((e) => e.provider === agent.provider);
|
||||
return entry?.models ?? null;
|
||||
}, [snapshotEntries, agent?.provider]);
|
||||
|
||||
const models = snapshotSelectedEntry?.models ?? null;
|
||||
const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading";
|
||||
const models = snapshotModels;
|
||||
|
||||
const agentProviderDefinitions = useMemo(() => {
|
||||
const definition = agent?.provider
|
||||
@@ -899,11 +900,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
|
||||
const agentProviderModels = useMemo(() => {
|
||||
const map = new Map<string, AgentModelDefinition[]>();
|
||||
if (agent?.provider && models) {
|
||||
map.set(agent.provider, models);
|
||||
if (agent?.provider && snapshotModels) {
|
||||
map.set(agent.provider, snapshotModels);
|
||||
}
|
||||
return map;
|
||||
}, [agent?.provider, models]);
|
||||
}, [agent?.provider, snapshotModels]);
|
||||
|
||||
const displayMode =
|
||||
availableModes.find((mode) => mode.id === agent?.currentModeId)?.label ||
|
||||
@@ -963,7 +964,6 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
}
|
||||
void client.setAgentMode(agentId, modeId).catch((error) => {
|
||||
console.warn("[AgentStatusBar] setAgentMode failed", error);
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
}}
|
||||
modelOptions={modelOptions}
|
||||
@@ -985,7 +985,6 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
});
|
||||
void client.setAgentModel(agentId, modelId).catch((error) => {
|
||||
console.warn("[AgentStatusBar] setAgentModel failed", error);
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
}}
|
||||
favoriteKeys={favoriteKeys}
|
||||
@@ -1021,7 +1020,6 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
}
|
||||
void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => {
|
||||
console.warn("[AgentStatusBar] setAgentThinkingOption failed", error);
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
}}
|
||||
features={agent.features}
|
||||
@@ -1044,11 +1042,10 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
});
|
||||
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
|
||||
console.warn("[AgentStatusBar] setAgentFeature failed", error);
|
||||
toast.error(toErrorMessage(error));
|
||||
});
|
||||
}}
|
||||
isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
|
||||
onModelSelectorOpen={() => refetchSnapshotIfStale(agent?.provider)}
|
||||
isModelLoading={snapshotIsLoading || snapshotIsFetching}
|
||||
onModelSelectorOpen={invalidateSnapshot}
|
||||
onDropdownClose={onDropdownClose}
|
||||
disabled={!client}
|
||||
/>
|
||||
@@ -1104,7 +1101,6 @@ export function DraftAgentStatusBar({
|
||||
const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || "";
|
||||
const effectiveSelectedThinkingOption =
|
||||
selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined;
|
||||
const hasSelectedProvider = selectedProvider !== null;
|
||||
|
||||
if (platformIsWeb) {
|
||||
return (
|
||||
@@ -1112,7 +1108,7 @@ export function DraftAgentStatusBar({
|
||||
<CombinedModelSelector
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
selectedProvider={selectedProvider ?? ""}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
onSelect={onSelectProviderAndModel}
|
||||
favoriteKeys={favoriteKeys}
|
||||
@@ -1128,22 +1124,20 @@ export function DraftAgentStatusBar({
|
||||
onOpen={onModelSelectorOpen}
|
||||
onClose={onDropdownClose}
|
||||
/>
|
||||
{selectedProvider ? (
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
modeOptions={mappedModeOptions}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
features={features}
|
||||
onSetFeature={onSetFeature}
|
||||
onDropdownClose={onDropdownClose}
|
||||
disabled={disabled}
|
||||
/>
|
||||
) : null}
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
modeOptions={mappedModeOptions}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
features={features}
|
||||
onSetFeature={onSetFeature}
|
||||
onDropdownClose={onDropdownClose}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1156,10 +1150,10 @@ export function DraftAgentStatusBar({
|
||||
return (
|
||||
<>
|
||||
<ControlledStatusBar
|
||||
provider={selectedProvider ?? ""}
|
||||
provider={selectedProvider}
|
||||
providerDefinitions={providerDefinitions}
|
||||
allProviderModels={allProviderModels}
|
||||
modeOptions={hasSelectedProvider ? mappedModeOptions : undefined}
|
||||
modeOptions={mappedModeOptions}
|
||||
selectedModeId={effectiveSelectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
modelOptions={modelOptions}
|
||||
@@ -1231,8 +1225,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
prefsButton: {
|
||||
height: 28,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
import {
|
||||
orderHeadForStreamRenderStrategy,
|
||||
orderTailForStreamRenderStrategy,
|
||||
resolveStreamRenderStrategy,
|
||||
} from "./stream-strategy";
|
||||
import { resolveStreamRenderStrategy } from "./stream-strategy-resolver";
|
||||
|
||||
export type StreamRenderSegments = {
|
||||
historyVirtualized: StreamItem[];
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./stream-strategy";
|
||||
export * from "./stream-strategy-resolver";
|
||||
export * from "./agent-stream-render-model";
|
||||
|
||||
@@ -66,7 +66,6 @@ import {
|
||||
} from "./use-bottom-anchor-controller";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import {
|
||||
@@ -125,7 +124,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
@@ -137,13 +136,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
);
|
||||
|
||||
const workspaceRoot = agent.cwd?.trim() || "";
|
||||
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
|
||||
workspaces: useSessionStore.getState().sessions[resolvedServerId]?.workspaces?.values(),
|
||||
workspaceDirectory: workspaceRoot,
|
||||
});
|
||||
const workspaceId = agent.projectPlacement?.checkout?.cwd?.trim() || workspaceRoot;
|
||||
const { requestDirectoryListing } = useFileExplorerActions({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId: workspaceId ?? undefined,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
});
|
||||
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
|
||||
@@ -183,14 +179,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return;
|
||||
}
|
||||
|
||||
if (workspaceId) {
|
||||
const route = prepareWorkspaceTab({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId,
|
||||
target: { kind: "file", path: normalized.file },
|
||||
});
|
||||
router.navigate(route);
|
||||
}
|
||||
const route = prepareWorkspaceTab({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId,
|
||||
target: { kind: "file", path: normalized.file },
|
||||
});
|
||||
router.navigate(route);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -199,21 +193,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
setCurrentPath: false,
|
||||
});
|
||||
|
||||
const checkout = {
|
||||
setExplorerTabForCheckout({
|
||||
serverId: resolvedServerId,
|
||||
cwd: agent.cwd,
|
||||
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
|
||||
};
|
||||
setExplorerTabForCheckout({ ...checkout, tab: "files" });
|
||||
openFileExplorerForCheckout({
|
||||
isCompact: isMobile,
|
||||
checkout,
|
||||
tab: "files",
|
||||
});
|
||||
openFileExplorer();
|
||||
},
|
||||
[
|
||||
agent.cwd,
|
||||
isMobile,
|
||||
openFileExplorerForCheckout,
|
||||
openFileExplorer,
|
||||
requestDirectoryListing,
|
||||
resolvedServerId,
|
||||
router,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { View, Text, ScrollView, Pressable, Modal } from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
|
||||
export interface Artifact {
|
||||
id: string;
|
||||
@@ -148,8 +147,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
|
||||
if (!artifact) {
|
||||
return null;
|
||||
}
|
||||
@@ -193,7 +190,7 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
||||
</View>
|
||||
{/* Content */}
|
||||
<ScrollView
|
||||
style={[styles.contentScroll, webScrollbarStyle]}
|
||||
style={styles.contentScroll}
|
||||
contentContainerStyle={styles.contentScrollContainer}
|
||||
>
|
||||
{artifact.type === "image" ? (
|
||||
@@ -203,11 +200,7 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.codeContainer}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={true}
|
||||
style={webScrollbarStyle}
|
||||
>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={true}>
|
||||
<Text style={styles.codeText}>{content}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -1,91 +0,0 @@
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X } from "lucide-react-native";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
|
||||
interface AttachmentPillProps {
|
||||
onOpen: () => void;
|
||||
onRemove: () => void;
|
||||
openAccessibilityLabel: string;
|
||||
removeAccessibilityLabel: string;
|
||||
disabled?: boolean;
|
||||
testID?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AttachmentPill({
|
||||
onOpen,
|
||||
onRemove,
|
||||
openAccessibilityLabel,
|
||||
removeAccessibilityLabel,
|
||||
disabled = false,
|
||||
testID,
|
||||
children,
|
||||
}: AttachmentPillProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isBodyHovered, setIsBodyHovered] = useState(false);
|
||||
const [isCloseHovered, setIsCloseHovered] = useState(false);
|
||||
const alwaysShow = isNative || isCompact;
|
||||
const showRemove = alwaysShow || isBodyHovered || isCloseHovered;
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<Pressable
|
||||
testID={testID}
|
||||
onPress={onOpen}
|
||||
disabled={disabled}
|
||||
onHoverIn={() => setIsBodyHovered(true)}
|
||||
onHoverOut={() => setIsBodyHovered(false)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={openAccessibilityLabel}
|
||||
style={styles.body}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={onRemove}
|
||||
disabled={disabled}
|
||||
onHoverIn={() => setIsCloseHovered(true)}
|
||||
onHoverOut={() => setIsCloseHovered(false)}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={removeAccessibilityLabel}
|
||||
style={[styles.closeButton, !showRemove && styles.closeButtonHidden]}
|
||||
>
|
||||
<X size={12} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
wrapper: {
|
||||
position: "relative",
|
||||
},
|
||||
body: {
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
overflow: "hidden",
|
||||
},
|
||||
closeButton: {
|
||||
position: "absolute",
|
||||
top: -8,
|
||||
left: -8,
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1,
|
||||
},
|
||||
closeButtonHidden: {
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
},
|
||||
}));
|
||||
@@ -1,14 +1,12 @@
|
||||
import { useRef } from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, GitBranch } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
|
||||
import { ScreenTitle } from "@/components/headers/screen-title";
|
||||
|
||||
interface BranchSwitcherProps {
|
||||
currentBranchName: string | null;
|
||||
@@ -26,7 +24,6 @@ export function BranchSwitcher({
|
||||
isGitCheckout,
|
||||
}: BranchSwitcherProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const anchorRef = useRef<View>(null);
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -44,15 +41,12 @@ export function BranchSwitcher({
|
||||
queryClient,
|
||||
});
|
||||
|
||||
const titleContent = (
|
||||
<View style={styles.titleRow}>
|
||||
{isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null}
|
||||
<ScreenTitle testID="workspace-header-title">{title}</ScreenTitle>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!currentBranchName) {
|
||||
return <View style={styles.branchSwitcherTrigger}>{titleContent}</View>;
|
||||
return (
|
||||
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -67,8 +61,11 @@ export function BranchSwitcher({
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
|
||||
>
|
||||
{titleContent}
|
||||
{!isCompact ? <ChevronDown size={12} color={theme.colors.foregroundMuted} /> : null}
|
||||
<GitBranch size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={branchOptions}
|
||||
@@ -101,31 +98,26 @@ export function BranchSwitcher({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
headerTitle: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: {
|
||||
xs: "400",
|
||||
md: "300",
|
||||
},
|
||||
color: theme.colors.foreground,
|
||||
flexShrink: 1,
|
||||
},
|
||||
branchSwitcherTrigger: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
minWidth: 0,
|
||||
marginLeft: {
|
||||
xs: -theme.spacing[2],
|
||||
md: 0,
|
||||
},
|
||||
paddingVertical: {
|
||||
xs: 0,
|
||||
md: theme.spacing[1],
|
||||
},
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
branchSwitcherTriggerHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -67,8 +67,8 @@ describe("combined model selector helpers", () => {
|
||||
expect(matchesSearch(rows[1]!, "gpt-5.4")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the selected trigger label model-only", () => {
|
||||
it("builds an explicit trigger label for the selected provider and model", () => {
|
||||
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
||||
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
||||
expect(buildSelectedTriggerLabel("Codex", "GPT-5.4")).toBe("GPT-5.4");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -533,22 +533,10 @@ export function CombinedModelSelector({
|
||||
return { kind: "provider", providerId, providerLabel: label };
|
||||
}, [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(
|
||||
(open: boolean) => {
|
||||
setIsOpen(open);
|
||||
setView(computeInitialView());
|
||||
setView(singleProviderView ?? { kind: "all" });
|
||||
if (open) {
|
||||
onOpen?.();
|
||||
} else {
|
||||
@@ -556,35 +544,33 @@ export function CombinedModelSelector({
|
||||
onClose?.();
|
||||
}
|
||||
},
|
||||
[onOpen, onClose, computeInitialView],
|
||||
[onOpen, onClose, singleProviderView],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(provider: string, modelId: string) => {
|
||||
onSelect(provider as AgentProvider, modelId);
|
||||
setIsOpen(false);
|
||||
setView(singleProviderView ?? { kind: "all" });
|
||||
setSearchQuery("");
|
||||
},
|
||||
[onSelect],
|
||||
[onSelect, singleProviderView],
|
||||
);
|
||||
|
||||
const hasSelectedProvider = selectedProvider.trim().length > 0;
|
||||
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
|
||||
const ProviderIcon = getProviderIcon(selectedProvider);
|
||||
const selectedProviderLabel = useMemo(
|
||||
() => resolveProviderLabel(providerDefinitions, selectedProvider),
|
||||
[providerDefinitions, selectedProvider],
|
||||
);
|
||||
|
||||
const selectedModelLabel = useMemo(() => {
|
||||
if (!selectedModel) {
|
||||
if (!hasSelectedProvider) {
|
||||
return "Select model";
|
||||
}
|
||||
return isLoading ? "Loading..." : "Select model";
|
||||
}
|
||||
const models = allProviderModels.get(selectedProvider);
|
||||
if (!models) {
|
||||
return isLoading ? "Loading..." : "Select model";
|
||||
}
|
||||
const model = models.find((entry) => entry.id === selectedModel);
|
||||
return model?.label ?? resolveDefaultModelLabel(models);
|
||||
}, [allProviderModels, hasSelectedProvider, isLoading, selectedModel, selectedProvider]);
|
||||
}, [allProviderModels, isLoading, selectedModel, selectedProvider]);
|
||||
|
||||
const desktopFixedHeight = useMemo(() => {
|
||||
if (view.kind !== "provider") {
|
||||
@@ -600,8 +586,8 @@ export function CombinedModelSelector({
|
||||
return selectedModelLabel;
|
||||
}
|
||||
|
||||
return buildSelectedTriggerLabel(selectedModelLabel);
|
||||
}, [selectedModelLabel]);
|
||||
return buildSelectedTriggerLabel(selectedProviderLabel, selectedModelLabel);
|
||||
}, [selectedModelLabel, selectedProviderLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (platformIsWeb) {
|
||||
@@ -647,12 +633,8 @@ export function CombinedModelSelector({
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
{ProviderIcon ? (
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
) : null}
|
||||
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
||||
{triggerLabel}
|
||||
</Text>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.triggerText}>{triggerLabel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</>
|
||||
)}
|
||||
@@ -663,6 +645,7 @@ export function CombinedModelSelector({
|
||||
onSelect={() => {}}
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
stackBehavior="push"
|
||||
anchorRef={anchorRef}
|
||||
desktopPlacement="top-start"
|
||||
desktopMinWidth={360}
|
||||
@@ -721,8 +704,6 @@ export function CombinedModelSelector({
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
height: 28,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "transparent",
|
||||
@@ -740,8 +721,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
opacity: 0.5,
|
||||
},
|
||||
triggerText: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
@@ -803,7 +782,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
level2Header: {},
|
||||
level2Header: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
backButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -13,7 +13,7 @@ export function resolveProviderLabel(
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSelectedTriggerLabel(modelLabel: string): string {
|
||||
export function buildSelectedTriggerLabel(providerLabel: string, modelLabel: string): string {
|
||||
return modelLabel;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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 +0,0 @@
|
||||
export * from "./composer-height-mirror.native";
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { RefObject } from "react";
|
||||
|
||||
interface Args {
|
||||
value: string;
|
||||
textareaRef: RefObject<unknown>;
|
||||
minHeight: number;
|
||||
maxHeight: number;
|
||||
onHeight: (height: number) => void;
|
||||
}
|
||||
|
||||
export function useComposerHeightMirror(_args: Args): void {
|
||||
// No-op on native: onContentSizeChange drives height natively.
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||
import type { RefObject } from "react";
|
||||
|
||||
interface Args {
|
||||
value: string;
|
||||
textareaRef: RefObject<HTMLElement | null>;
|
||||
minHeight: number;
|
||||
maxHeight: number;
|
||||
onHeight: (height: number) => void;
|
||||
}
|
||||
|
||||
const COPIED_STYLES = [
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"fontStyle",
|
||||
"fontVariant",
|
||||
"lineHeight",
|
||||
"letterSpacing",
|
||||
"wordSpacing",
|
||||
"textTransform",
|
||||
"textIndent",
|
||||
"whiteSpace",
|
||||
"wordWrap",
|
||||
"overflowWrap",
|
||||
"wordBreak",
|
||||
"tabSize",
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
] as const;
|
||||
|
||||
export function useComposerHeightMirror({
|
||||
value,
|
||||
textareaRef,
|
||||
minHeight,
|
||||
maxHeight,
|
||||
onHeight,
|
||||
}: Args): void {
|
||||
const paramsRef = useRef({ value, minHeight, maxHeight, onHeight });
|
||||
paramsRef.current = { value, minHeight, maxHeight, onHeight };
|
||||
|
||||
const mirrorRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const mirror = document.createElement("textarea");
|
||||
mirror.setAttribute("aria-hidden", "true");
|
||||
mirror.setAttribute("tabindex", "-1");
|
||||
mirror.readOnly = true;
|
||||
mirror.rows = 1;
|
||||
const style = mirror.style;
|
||||
style.position = "absolute";
|
||||
style.top = "0";
|
||||
style.left = "0";
|
||||
style.visibility = "hidden";
|
||||
style.pointerEvents = "none";
|
||||
style.overflow = "hidden";
|
||||
style.border = "0";
|
||||
style.margin = "0";
|
||||
style.resize = "none";
|
||||
style.zIndex = "-1";
|
||||
style.boxSizing = "border-box";
|
||||
document.body.appendChild(mirror);
|
||||
mirrorRef.current = mirror;
|
||||
return () => {
|
||||
mirror.remove();
|
||||
mirrorRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const mirror = mirrorRef.current;
|
||||
const source = textareaRef.current;
|
||||
if (!mirror || !source || typeof window === "undefined") return;
|
||||
if (!(source instanceof HTMLElement)) return;
|
||||
|
||||
const cs = window.getComputedStyle(source);
|
||||
const ms = mirror.style;
|
||||
for (const prop of COPIED_STYLES) {
|
||||
ms[prop] = cs[prop];
|
||||
}
|
||||
ms.width = `${source.clientWidth}px`;
|
||||
|
||||
const { value, minHeight, maxHeight, onHeight } = paramsRef.current;
|
||||
// Trailing newline is collapsed by textarea measurement — pad with a space.
|
||||
mirror.value = value.endsWith("\n") ? `${value} ` : value;
|
||||
|
||||
const next = Math.max(minHeight, Math.min(maxHeight, mirror.scrollHeight));
|
||||
onHeight(next);
|
||||
}, [textareaRef]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measure();
|
||||
}, [value, measure]);
|
||||
|
||||
useEffect(() => {
|
||||
const source = textareaRef.current;
|
||||
if (!source || !(source instanceof HTMLElement)) return;
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => measure());
|
||||
observer.observe(source);
|
||||
return () => observer.disconnect();
|
||||
}, [textareaRef, measure]);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ type ContextWindowMeterProps = {
|
||||
usedTokens: number;
|
||||
};
|
||||
|
||||
const SVG_SIZE = 16;
|
||||
const SVG_SIZE = 20;
|
||||
const CENTER = SVG_SIZE / 2;
|
||||
const RADIUS = 7;
|
||||
const STROKE_WIDTH = 2.25;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
|
||||
interface DiffScrollProps {
|
||||
children: React.ReactNode;
|
||||
@@ -15,14 +14,12 @@ export function DiffScroll({
|
||||
style,
|
||||
contentContainerStyle,
|
||||
}: DiffScrollProps) {
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={[style, webScrollbarStyle]}
|
||||
style={style}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width)}
|
||||
>
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
interface DiffStatProps {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
const compactFormatter = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
export function formatDiffCount(value: number): string {
|
||||
return compactFormatter.format(value).toLowerCase();
|
||||
}
|
||||
|
||||
export function DiffStat({ additions, deletions }: DiffStatProps) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.additions}>+{formatDiffCount(additions)}</Text>
|
||||
<Text style={styles.deletions}>-{formatDiffCount(deletions)}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
flexShrink: 0,
|
||||
},
|
||||
additions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.diffAddition,
|
||||
},
|
||||
deletions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.diffDeletion,
|
||||
},
|
||||
}));
|
||||
@@ -4,7 +4,6 @@ import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import type { DiffLine, DiffSegment } from "@/utils/tool-call-parsers";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { getCodeInsets } from "./code-insets";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
@@ -24,7 +23,6 @@ export function DiffViewer({
|
||||
fillAvailableHeight = false,
|
||||
}: DiffViewerProps) {
|
||||
const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
|
||||
const webScrollbarStyle = useWebScrollbarStyle();
|
||||
|
||||
if (!diffLines.length) {
|
||||
return (
|
||||
@@ -40,7 +38,6 @@ export function DiffViewer({
|
||||
styles.verticalScroll,
|
||||
maxHeight !== undefined && { maxHeight },
|
||||
fillAvailableHeight && styles.fillHeight,
|
||||
webScrollbarStyle,
|
||||
]}
|
||||
contentContainerStyle={styles.verticalContent}
|
||||
nestedScrollEnabled
|
||||
@@ -50,7 +47,6 @@ export function DiffViewer({
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
style={webScrollbarStyle}
|
||||
contentContainerStyle={styles.horizontalContent}
|
||||
onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)}
|
||||
>
|
||||
|
||||
@@ -142,7 +142,7 @@ export function DraggableList<T>({
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 6,
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
|
||||
@@ -12,12 +12,8 @@ import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-nativ
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X } from "lucide-react-native";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { PrPane } from "./pr-pane";
|
||||
import { usePrPaneData } from "@/hooks/use-pr-pane-data";
|
||||
import {
|
||||
usePanelStore,
|
||||
selectIsFileExplorerOpen,
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||
type ExplorerTab,
|
||||
@@ -53,9 +49,9 @@ export function ExplorerSidebar({
|
||||
const isScreenFocused = useIsFocused();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: isMobile }));
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
@@ -82,6 +78,9 @@ export function ExplorerSidebar({
|
||||
}
|
||||
}, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]);
|
||||
|
||||
// Derive isOpen from the unified panel state
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -102,20 +101,18 @@ export function ExplorerSidebar({
|
||||
logExplorerSidebar("handleClose", {
|
||||
reason,
|
||||
isOpen,
|
||||
mobileView,
|
||||
desktopFileExplorerOpen,
|
||||
});
|
||||
if (isMobile) {
|
||||
showMobileAgent();
|
||||
return;
|
||||
}
|
||||
closeDesktopFileExplorer();
|
||||
closeToAgent();
|
||||
},
|
||||
[closeDesktopFileExplorer, isMobile, isOpen, showMobileAgent],
|
||||
[closeToAgent, desktopFileExplorerOpen, isOpen, mobileView],
|
||||
);
|
||||
|
||||
const handleCloseFromGesture = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
showMobileAgent();
|
||||
}, [gestureAnimatingRef, showMobileAgent]);
|
||||
closeToAgent();
|
||||
}, [closeToAgent, gestureAnimatingRef]);
|
||||
|
||||
const enableSidebarCloseGesture = isMobile && isOpen;
|
||||
|
||||
@@ -292,7 +289,6 @@ export function ExplorerSidebar({
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile={isMobile}
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</Animated.View>
|
||||
@@ -325,7 +321,6 @@ export function ExplorerSidebar({
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isMobile={false}
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</View>
|
||||
@@ -342,7 +337,6 @@ interface SidebarContentProps {
|
||||
workspaceRoot: string;
|
||||
isGit: boolean;
|
||||
isMobile: boolean;
|
||||
isOpen: boolean;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
@@ -355,24 +349,11 @@ function SidebarContent({
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
isMobile,
|
||||
isOpen,
|
||||
onOpenFile,
|
||||
}: SidebarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const padding = useWindowControlsPadding("explorerSidebar");
|
||||
const canQueryPullRequest = isGit && Boolean(workspaceRoot);
|
||||
const prPane = usePrPaneData({
|
||||
serverId,
|
||||
cwd: workspaceRoot,
|
||||
enabled: canQueryPullRequest && isOpen,
|
||||
timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen,
|
||||
});
|
||||
const hasPullRequest = prPane.prNumber !== null;
|
||||
const requestedTab: ExplorerTab =
|
||||
!isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab;
|
||||
const resolvedTab: ExplorerTab =
|
||||
requestedTab === "pr" && !hasPullRequest ? "changes" : requestedTab;
|
||||
const prTabLabel = prPane.prNumber === null ? "" : `#${prPane.prNumber}`;
|
||||
const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab;
|
||||
|
||||
return (
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
@@ -400,23 +381,6 @@ function SidebarContent({
|
||||
Files
|
||||
</Text>
|
||||
</Pressable>
|
||||
{isGit && hasPullRequest && (
|
||||
<Pressable
|
||||
testID="explorer-tab-pr"
|
||||
style={[styles.tab, resolvedTab === "pr" && styles.tabActive]}
|
||||
onPress={() => onTabPress("pr")}
|
||||
>
|
||||
<GitHubIcon
|
||||
size={13}
|
||||
color={
|
||||
resolvedTab === "pr" ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
<Text style={[styles.tabText, resolvedTab === "pr" && styles.tabTextActive]}>
|
||||
{prTabLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.headerRightSection}>
|
||||
{isMobile && (
|
||||
@@ -445,7 +409,6 @@ function SidebarContent({
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
)}
|
||||
{resolvedTab === "pr" && prPane.data && <PrPane data={prPane.data} />}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -8,8 +8,18 @@ import {
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
@@ -24,7 +34,6 @@ import {
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -158,34 +167,54 @@ export function FileExplorerPane({
|
||||
if (hasInitializedRef.current) {
|
||||
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;
|
||||
void requestDirectoryListing(".", {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
}).then((succeeded) => {
|
||||
if (!succeeded) {
|
||||
hasInitializedRef.current = false;
|
||||
return;
|
||||
}
|
||||
const persistedPaths =
|
||||
usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
|
||||
if (persistedPaths) {
|
||||
for (const path of persistedPaths) {
|
||||
if (path !== ".") {
|
||||
void requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
const persistedPaths =
|
||||
usePanelStore.getState().expandedPathsByWorkspace[workspaceStateKey ?? ""];
|
||||
if (persistedPaths) {
|
||||
for (const path of persistedPaths) {
|
||||
if (path !== ".") {
|
||||
void requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [hasWorkspaceScope, requestDirectoryListing, workspaceStateKey]);
|
||||
|
||||
// Expand ancestor directories when a file is selected (e.g., from an inline path click)
|
||||
useEffect(() => {
|
||||
if (!selectedEntryPath || !workspaceStateKey) {
|
||||
return;
|
||||
}
|
||||
const parentDir = getParentDirectory(selectedEntryPath);
|
||||
const ancestors = getAncestorDirectories(parentDir);
|
||||
const newPaths = ancestors.filter((path) => !expandedPaths.has(path));
|
||||
if (newPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
setExpandedPathsForWorkspace(workspaceStateKey, [...Array.from(expandedPaths), ...newPaths]);
|
||||
newPaths.forEach((path) => {
|
||||
if (!directories.has(path)) {
|
||||
void requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [
|
||||
directories,
|
||||
workspaceStateKey,
|
||||
expandedPaths,
|
||||
requestDirectoryListing,
|
||||
selectedEntryPath,
|
||||
setExpandedPathsForWorkspace,
|
||||
]);
|
||||
|
||||
const handleToggleDirectory = useCallback(
|
||||
(entry: ExplorerEntry) => {
|
||||
if (!workspaceStateKey) {
|
||||
@@ -303,6 +332,48 @@ export function FileExplorerPane({
|
||||
const handleRefresh = useCallback(() => {
|
||||
void refetchExplorer();
|
||||
}, [refetchExplorer]);
|
||||
const refreshIconRotation = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRefreshFetching) {
|
||||
refreshIconRotation.value = 0;
|
||||
refreshIconRotation.value = withRepeat(
|
||||
withTiming(360, {
|
||||
duration: 700,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
cancelAnimation(refreshIconRotation);
|
||||
const remainder = refreshIconRotation.value % 360;
|
||||
if (Math.abs(remainder) < 0.001) {
|
||||
refreshIconRotation.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = 360 - remainder;
|
||||
const duration = Math.max(80, Math.round((remaining / 360) * 700));
|
||||
refreshIconRotation.value = withTiming(
|
||||
360,
|
||||
{
|
||||
duration,
|
||||
easing: Easing.linear,
|
||||
},
|
||||
(finished) => {
|
||||
if (finished) {
|
||||
refreshIconRotation.value = 0;
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [isRefreshFetching, refreshIconRotation]);
|
||||
|
||||
const refreshIconAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotate: `${refreshIconRotation.value}deg` }],
|
||||
}));
|
||||
|
||||
const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name";
|
||||
|
||||
@@ -512,15 +583,11 @@ export function FileExplorerPane({
|
||||
(hovered || pressed) && styles.iconButtonHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isRefreshFetching ? "Refreshing files" : "Refresh files"}
|
||||
accessibilityLabel="Refresh files"
|
||||
>
|
||||
<View style={styles.refreshIcon}>
|
||||
{isRefreshFetching ? (
|
||||
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
<Animated.View style={[styles.refreshIcon, refreshIconAnimatedStyle]}>
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
</View>
|
||||
<FlatList
|
||||
@@ -606,6 +673,35 @@ function buildTreeRows({
|
||||
return rows;
|
||||
}
|
||||
|
||||
function getParentDirectory(path: string): string {
|
||||
const normalized = path.replace(/\/+$/, "");
|
||||
if (!normalized || normalized === ".") {
|
||||
return ".";
|
||||
}
|
||||
const lastSlash = normalized.lastIndexOf("/");
|
||||
if (lastSlash === -1) {
|
||||
return ".";
|
||||
}
|
||||
const dir = normalized.slice(0, lastSlash);
|
||||
return dir.length > 0 ? dir : ".";
|
||||
}
|
||||
|
||||
function getAncestorDirectories(directory: string): string[] {
|
||||
const trimmed = directory.replace(/^\.\/+/, "").replace(/\/+$/, "");
|
||||
if (!trimmed || trimmed === ".") {
|
||||
return ["."];
|
||||
}
|
||||
|
||||
const parts = trimmed.split("/").filter(Boolean);
|
||||
const ancestors: string[] = ["."];
|
||||
let acc = "";
|
||||
for (const part of parts) {
|
||||
acc = acc ? `${acc}/${part}` : part;
|
||||
ancestors.push(acc);
|
||||
}
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string {
|
||||
if (!state) {
|
||||
return ".";
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user