mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0a0cb4a99 | ||
|
|
342e92d0c7 | ||
|
|
0170eba233 | ||
|
|
b6103a59da | ||
|
|
24526a3b23 | ||
|
|
6d1aa1f415 | ||
|
|
4b973ae9ec | ||
|
|
e02e8426d4 | ||
|
|
e4188f5222 | ||
|
|
8262fb42af | ||
|
|
3176f844e7 | ||
|
|
8234ecb7ba | ||
|
|
adb9a57cfc | ||
|
|
93c14cb8ad | ||
|
|
a00152290f | ||
|
|
8e4cbf8ca6 | ||
|
|
0737c5c973 | ||
|
|
c4f9874e1f | ||
|
|
990bca71b7 | ||
|
|
f431ebee6d | ||
|
|
93189148f5 | ||
|
|
3baba543a4 | ||
|
|
f20393dbb7 | ||
|
|
3ac182cffc | ||
|
|
5cede0a7bb | ||
|
|
53c14d9855 | ||
|
|
0ea41378a4 | ||
|
|
00759e7994 | ||
|
|
e3eb333ddc | ||
|
|
a025f17a73 | ||
|
|
a4cb7431d8 | ||
|
|
5696cdb455 | ||
|
|
5a56835db3 | ||
|
|
698d549983 | ||
|
|
fa1b3e88f0 | ||
|
|
5d8dc800fc | ||
|
|
dbfd42da46 | ||
|
|
2894917a1c | ||
|
|
9c2d47ab34 | ||
|
|
d787aefa4c | ||
|
|
8307a0ca6f | ||
|
|
7aa49b2905 | ||
|
|
d594bce153 | ||
|
|
a630986d06 | ||
|
|
724a499413 | ||
|
|
8aa1530be1 | ||
|
|
1908ab8765 | ||
|
|
5ad6cff039 | ||
|
|
8a463c55c8 | ||
|
|
aecb300073 | ||
|
|
4911b627f2 | ||
|
|
a5b82d2a3b | ||
|
|
09bf981f13 | ||
|
|
3cf92ad6c5 | ||
|
|
153fa42a95 | ||
|
|
6d205f8853 | ||
|
|
5468089ac0 |
78
.github/workflows/ci.yml
vendored
78
.github/workflows/ci.yml
vendored
@@ -69,18 +69,18 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Build server dependency
|
||||
run: npm run build --workspace=@getpaseo/server
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
- name: Typecheck all packages
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Verify public package contents
|
||||
run: |
|
||||
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol
|
||||
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client
|
||||
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
|
||||
|
||||
server-tests:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -107,11 +107,8 @@ jobs:
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
- name: Build server dependencies
|
||||
run: npm run build:server-deps
|
||||
|
||||
- name: Run server tests
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
@@ -137,14 +134,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Build server dependency
|
||||
run: npm run build --workspace=@getpaseo/server
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
- name: Run desktop tests
|
||||
run: npm run test --workspace=@getpaseo/desktop
|
||||
@@ -165,12 +156,37 @@ jobs:
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
- name: Build app dependencies
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Run app unit tests
|
||||
run: npm run test --workspace=@getpaseo/app
|
||||
|
||||
sdk-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build client dependencies
|
||||
run: npm run build:client
|
||||
|
||||
- name: Run protocol tests
|
||||
run: npm run test --workspace=@getpaseo/protocol
|
||||
|
||||
- name: Run client tests
|
||||
run: npm run test --workspace=@getpaseo/client
|
||||
|
||||
- name: Typecheck client examples
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
playwright:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -187,14 +203,11 @@ jobs:
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
- name: Build app dependencies
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Build server dependency
|
||||
run: npm run build --workspace=@getpaseo/server
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
@@ -228,7 +241,7 @@ jobs:
|
||||
run: npm ci
|
||||
|
||||
- name: Build relay
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
run: npm run build:relay
|
||||
|
||||
- name: Run relay tests
|
||||
run: npm run test --workspace=@getpaseo/relay
|
||||
@@ -254,9 +267,6 @@ jobs:
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Run CLI tests
|
||||
run: npm run test --workspace=@getpaseo/cli
|
||||
env:
|
||||
|
||||
4
.github/workflows/deploy-app.yml
vendored
4
.github/workflows/deploy-app.yml
vendored
@@ -28,8 +28,8 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
- name: Build app dependencies
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/app
|
||||
|
||||
1
.github/workflows/deploy-website.yml
vendored
1
.github/workflows/deploy-website.yml
vendored
@@ -5,6 +5,7 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "CHANGELOG.md"
|
||||
- "public-docs/**"
|
||||
- "packages/website/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
|
||||
24
.github/workflows/desktop-release.yml
vendored
24
.github/workflows/desktop-release.yml
vendored
@@ -183,6 +183,14 @@ jobs:
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-macos-${{ matrix.electron_arch }}
|
||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*.dmg
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload manifest artifact
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -273,6 +281,14 @@ jobs:
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-linux
|
||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload manifest artifact
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -360,6 +376,14 @@ jobs:
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-windows
|
||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload manifest artifact
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
2
.github/workflows/nix.yml
vendored
2
.github/workflows/nix.yml
vendored
@@ -10,6 +10,8 @@ on:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/client/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
|
||||
41
CHANGELOG.md
41
CHANGELOG.md
@@ -1,5 +1,46 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.85 - 2026-05-29
|
||||
|
||||
### Added
|
||||
|
||||
- **Opus 4.8 in the Claude model picker**, with a 1M-context variant
|
||||
|
||||
### Improved
|
||||
|
||||
- Archiving a worktree now keeps its agents under the archived list instead of removing them
|
||||
- Archiving an agent cleans up any schedules targeting it
|
||||
|
||||
## 0.1.84 - 2026-05-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Auto-accept tool calls for OpenCode agents**
|
||||
|
||||
### Improved
|
||||
|
||||
- Copy an OpenCode resume command to continue the session outside Paseo
|
||||
- Model selector lists every enabled provider, with a Retry button when one fails to load
|
||||
- Provider settings are easier to search and manage
|
||||
- Other agents connecting to Paseo via MCP see the same providers, models, and modes as the app ([#1198](https://github.com/getpaseo/paseo/pull/1198))
|
||||
- OpenCode Edit tool calls render as inline diffs
|
||||
- Typing a slash command shows the best match first
|
||||
- Daemon starts faster on workspaces with many git folders
|
||||
- Markdown lists have tighter spacing
|
||||
- Less jank when streaming agent responses
|
||||
- User message footer controls align with the rest of the chat
|
||||
- Agent mode controls use a cleaner monochrome treatment
|
||||
- Compact layouts move the context ring to the footer right edge
|
||||
|
||||
### Fixed
|
||||
|
||||
- Allow selecting text in the chat on mobile ([#1153](https://github.com/getpaseo/paseo/pull/1153) by [@muzhi1991](https://github.com/muzhi1991))
|
||||
- Submitting a Pi question no longer looks like a second prompt opened ([#1188](https://github.com/getpaseo/paseo/pull/1188) by [@yuruiz](https://github.com/yuruiz))
|
||||
- Daemon memory leak from unbounded workspace git caches ([#1200](https://github.com/getpaseo/paseo/pull/1200))
|
||||
- Provider diagnostics include the command override binary path ([#1191](https://github.com/getpaseo/paseo/pull/1191))
|
||||
- OpenCode MCP servers connect correctly when the daemon binds to wildcard addresses
|
||||
- Tool calls from MCP servers that return non-spec output no longer fail validation
|
||||
|
||||
## 0.1.83 - 2026-05-26
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -71,8 +71,9 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
||||
- 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 and lint after every change.**
|
||||
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace (especially CLI depending on server/daemon types), rebuild the owning package first so `dist` declarations are current:
|
||||
- `npm run build:daemon` — rebuild highlight, relay, server, and CLI when daemon/server/CLI types may be stale.
|
||||
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so `dist` declarations are current:
|
||||
- `npm run build:client` — rebuild protocol and client declarations.
|
||||
- `npm run build:server` — rebuild highlight, relay, protocol, client, server, and CLI when server/CLI types may be stale.
|
||||
- Do not patch inferred callback parameters or add local duplicate types just to silence stale declaration errors.
|
||||
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
|
||||
- **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script:
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
<a href="https://discord.gg/jz8T2uahpH">
|
||||
<img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord">
|
||||
</a>
|
||||
<a href="https://www.reddit.com/r/PaseoAI/">
|
||||
<img src="https://img.shields.io/badge/Reddit-555?logo=reddit" alt="Reddit">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, and Pi agents.</p>
|
||||
@@ -131,8 +134,8 @@ npm run dev:app
|
||||
npm run dev:desktop
|
||||
npm run dev:website
|
||||
|
||||
# build the daemon
|
||||
npm run build:daemon
|
||||
# build the server stack
|
||||
npm run build:server
|
||||
|
||||
# repo-wide checks
|
||||
npm run typecheck
|
||||
|
||||
@@ -68,9 +68,20 @@ All paths are under `packages/server/src/`.
|
||||
| `server/schedule/` | Cron-based scheduled agents |
|
||||
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
|
||||
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
|
||||
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
|
||||
| `shared/messages.ts` | Zod schemas for the entire wire protocol |
|
||||
| `shared/binary-frames/` | Terminal stream and file transfer binary frame codecs |
|
||||
|
||||
### `packages/protocol` — Wire schemas and shared protocol types
|
||||
|
||||
The source of truth for WebSocket messages, binary frame codecs, endpoint parsing,
|
||||
agent timeline types, provider config schemas, and other values shared by daemon
|
||||
and clients. Server, app, CLI, and `@getpaseo/client` all depend on this package;
|
||||
it does not depend on the server.
|
||||
|
||||
### `packages/client` — Daemon client library and SDK facade
|
||||
|
||||
Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient`
|
||||
facade. App and CLI may import the low-level driver from
|
||||
`@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped
|
||||
code imports from `@getpaseo/client`.
|
||||
|
||||
### `packages/app` — Mobile + web client (Expo)
|
||||
|
||||
@@ -126,7 +137,7 @@ TanStack Router + Cloudflare Workers. Serves paseo.sh.
|
||||
|
||||
## WebSocket protocol
|
||||
|
||||
All clients speak the same WebSocket protocol over a single connection that mixes JSON text frames and a small binary framing for terminal streams. Schemas live in `packages/server/src/shared/messages.ts`.
|
||||
All clients speak the same WebSocket protocol over a single connection that mixes JSON text frames and a small binary framing for terminal streams. Schemas live in `packages/protocol/src/messages.ts`.
|
||||
|
||||
**Handshake:**
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`)
|
||||
- [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)
|
||||
- [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint)
|
||||
- [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)
|
||||
@@ -59,29 +60,7 @@ Required fields for custom providers:
|
||||
- `extends` — which built-in provider to inherit from (or `"acp"`)
|
||||
- `label` — display name in the UI
|
||||
|
||||
### Codex with an OpenAI-compatible endpoint
|
||||
|
||||
Custom providers that extend `"codex"` can point Codex at an OpenAI-compatible API by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. Paseo still passes those variables through to the Codex app-server process, and also maps them into Codex's thread config (`model_provider` / `model_providers`) because Codex reads provider routing from config rather than from `OPENAI_BASE_URL`.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-codex": {
|
||||
"extends": "codex",
|
||||
"label": "My Codex",
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "sk-...",
|
||||
"OPENAI_BASE_URL": "https://custom-relay.example.com"
|
||||
},
|
||||
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the base URL does not end in `/v1`, Paseo appends `/v1` for Codex's OpenAI-compatible provider config. If it already ends in `/v1`, Paseo leaves it as-is.
|
||||
See [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint) below for the dedicated Codex example.
|
||||
|
||||
---
|
||||
|
||||
@@ -206,6 +185,62 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
|
||||
|
||||
---
|
||||
|
||||
## Codex with a custom OpenAI-compatible endpoint
|
||||
|
||||
Codex talks to OpenAI's Responses API by default. Custom providers that extend `"codex"` can point Codex at any OpenAI-compatible endpoint (OpenRouter, LiteLLM, vLLM, llama.cpp server, an internal gateway, etc.) by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`.
|
||||
|
||||
Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone.
|
||||
|
||||
### Setup
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-codex": {
|
||||
"extends": "codex",
|
||||
"label": "My Codex",
|
||||
"description": "Codex via custom OpenAI-compatible endpoint",
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "sk-...",
|
||||
"OPENAI_BASE_URL": "https://custom-relay.example.com"
|
||||
},
|
||||
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### What Paseo wires up
|
||||
|
||||
Under the hood, for each custom Codex provider Paseo injects this into Codex's config:
|
||||
|
||||
```toml
|
||||
model_provider = "my-codex"
|
||||
|
||||
[model_providers.my-codex]
|
||||
name = "My Codex"
|
||||
base_url = "https://custom-relay.example.com/v1"
|
||||
wire_api = "responses"
|
||||
env_key = "OPENAI_API_KEY"
|
||||
requires_openai_auth = false
|
||||
```
|
||||
|
||||
- `base_url` — taken from `OPENAI_BASE_URL`. If it does not already end in `/v1`, Paseo appends `/v1`. Trailing slashes are stripped.
|
||||
- `wire_api` — always `"responses"` (OpenAI Responses API protocol).
|
||||
- `env_key` — set to `"OPENAI_API_KEY"` when that env var is present and non-empty, so Codex reads the key from the same env var Paseo passes through.
|
||||
- `requires_openai_auth` — forced to `false` when `OPENAI_API_KEY` is provided, so Codex skips its built-in OpenAI login flow.
|
||||
|
||||
### Notes
|
||||
|
||||
- The endpoint must speak the OpenAI **Responses API**, not just chat completions. Many gateways (OpenRouter, LiteLLM) support both — pick the Responses-compatible route.
|
||||
- Set `models` explicitly. Custom endpoints expose their own model IDs (`anthropic/claude-opus-4-7`, `qwen/qwen3-coder`, `local/llama`, etc.), and Paseo does not discover them automatically for Codex.
|
||||
- To run multiple endpoints side-by-side, define multiple entries that each extend `"codex"` with different IDs, labels, and env. Each appears as its own provider in the app.
|
||||
- If you only want to override the binary (e.g. a nightly Codex build) without changing the endpoint, omit `OPENAI_BASE_URL` and use `command` instead — see [Custom binary for a provider](#custom-binary-for-a-provider).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -171,6 +171,8 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
|
||||
All fields are optional with sensible defaults.
|
||||
|
||||
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
|
||||
|
||||
---
|
||||
|
||||
## 3. Schedule
|
||||
|
||||
@@ -109,23 +109,28 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
|
||||
}
|
||||
```
|
||||
|
||||
## Build sync gotchas
|
||||
## Built workspace packages
|
||||
|
||||
The daemon and CLI consume sibling workspaces from compiled `dist/` output, not `src/`. When you change a workspace that something else imports, rebuild the producer first or the consumer will speak a stale protocol and fail with handshake warnings, timeouts, or stale type errors.
|
||||
Package imports resolve through package exports to compiled `dist/` output, not sibling `src/` files. This is true in local dev and in published packages: the app, daemon, CLI, and SDK consumers should all exercise the same runtime paths.
|
||||
|
||||
The fastest way to keep this consistent is to rebuild the whole daemon stack with one command:
|
||||
`npm run dev`, `npm run dev:server`, and `npm run dev:app` build the workspace packages they need once, then keep `@getpaseo/protocol` and `@getpaseo/client` fresh with TypeScript watch builds while the daemon or Expo runs. If you change protocol schemas or client code outside those watch workflows, rebuild the producer before trusting runtime behavior.
|
||||
|
||||
Use the named root build targets instead of remembering workspace dependency chains:
|
||||
|
||||
```bash
|
||||
npm run build:daemon
|
||||
npm run build:client # protocol -> client
|
||||
npm run build:server-deps # highlight -> relay -> protocol -> client
|
||||
npm run build:server # server-deps -> server -> cli
|
||||
npm run build:app-deps # highlight -> protocol -> client -> expo-two-way-audio
|
||||
```
|
||||
|
||||
This rebuilds, in order, `@getpaseo/highlight` → `@getpaseo/relay` → `@getpaseo/server` → `@getpaseo/cli`. Use it whenever you have changed any of those four and need clean cross-package types or runtime behavior.
|
||||
Use `npm run build:server` whenever you have changed any daemon/server-facing package and need clean cross-package types or runtime behavior.
|
||||
|
||||
For tighter loops, you can rebuild a single workspace:
|
||||
|
||||
- Changed `packages/relay/src/*`: `npm run build --workspace=@getpaseo/relay` (server imports `@getpaseo/relay` from `dist/*`).
|
||||
- Changed `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types: `npm run build --workspace=@getpaseo/server` (CLI imports `@getpaseo/server` via package exports resolving to `dist/*`).
|
||||
- Changed `packages/highlight/src/*`: `npm run build --workspace=@getpaseo/highlight` (server depends on it).
|
||||
- Changed `packages/protocol/src/*` or `packages/client/src/*`: `npm run build:client`.
|
||||
- Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`.
|
||||
- Changed app build dependencies: `npm run build:app-deps`.
|
||||
|
||||
## CLI reference
|
||||
|
||||
|
||||
211
docs/diagnostics/git-snapshot-startup-reshaping-2026-05-27.md
Normal file
211
docs/diagnostics/git-snapshot-startup-reshaping-2026-05-27.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# Git Snapshot Startup Reshaping - 2026-05-27
|
||||
|
||||
## What changed
|
||||
|
||||
The sidebar PR badge no longer has a special per-row fetch path. It is derived from the workspace snapshot, the same way the sidebar already gets branch/diff metadata.
|
||||
|
||||
```text
|
||||
daemon startup / workspace subscription
|
||||
-> WorkspaceGitService.refreshSnapshot(cwd)
|
||||
-> getCheckoutSnapshotFacts(cwd)
|
||||
-> getCheckoutStatus(cwd, { facts })
|
||||
-> getCheckoutShortstat(cwd, { facts })
|
||||
-> getPullRequestStatus(cwd, github, ..., { facts })
|
||||
-> WorkspaceGitRuntimeSnapshot
|
||||
-> session workspace descriptor githubRuntime.pullRequest
|
||||
-> app useSidebarWorkspacesList()
|
||||
-> SidebarWorkspaceEntry.prHint
|
||||
-> Sidebar row badge + hover card checks
|
||||
```
|
||||
|
||||
The remaining `checkout_pr_status_request` path is still present for explicit PR surfaces and compatibility, but the sidebar row badge no longer calls `useWorkspacePrHint()` and therefore no longer generates ad hoc checkout PR status requests per visible row.
|
||||
|
||||
## Shared Git Facts
|
||||
|
||||
`getCheckoutSnapshotFacts()` is now the first git read in the workspace snapshot builder. It gathers facts that were previously rediscovered by separate functions:
|
||||
|
||||
- worktree root: `rev-parse --show-toplevel`
|
||||
- current branch: `rev-parse --abbrev-ref HEAD`
|
||||
- origin remote URL
|
||||
- Paseo worktree ownership and stored base ref
|
||||
- resolved base ref and best comparison base
|
||||
- main repo root
|
||||
- branch remote/merge config
|
||||
- tracked origin branch
|
||||
- pull request lookup target for fork/PR worktrees
|
||||
|
||||
Those facts are then passed through `CheckoutContext` so status, shortstat, and PR status reuse the same answers instead of independently re-reading them.
|
||||
|
||||
## Current Data Flow
|
||||
|
||||
```text
|
||||
Workspace subscription / fetch_workspaces
|
||||
-> session workspace registry
|
||||
-> workspaceGitService.getSnapshot(cwd, includeGitHub)
|
||||
-> refresh queue/throttle/dedupe per normalized cwd
|
||||
-> refreshGitSnapshot()
|
||||
-> getCheckoutSnapshotFacts()
|
||||
-> getCheckoutStatus({ facts })
|
||||
-> getCheckoutShortstat({ facts })
|
||||
-> refreshGitHubSnapshot()
|
||||
-> getPullRequestStatus({ facts })
|
||||
-> cached WorkspaceGitRuntimeSnapshot
|
||||
-> WorkspaceDescriptorPayload.gitRuntime
|
||||
-> WorkspaceDescriptorPayload.githubRuntime
|
||||
-> app session store
|
||||
-> useSidebarWorkspacesList()
|
||||
-> diffStat from descriptor
|
||||
-> prHint from descriptor.githubRuntime.pullRequest
|
||||
```
|
||||
|
||||
## Startup Benchmark
|
||||
|
||||
Added deterministic real-home benchmark:
|
||||
|
||||
`packages/server/scripts/benchmark-startup-git-real-home.ts`
|
||||
|
||||
The script freezes the current Paseo home using the same metadata-copy shape as `scripts/dev-home.sh`: JSON under `agents`, JSON under `projects`, and `config.json`. It then starts an isolated in-process daemon against that frozen home, subscribes to workspaces/agents, records git invocations through `runGitCommand`, and reports elapsed time, git count, max concurrency, CPU, and memory deltas.
|
||||
|
||||
The frozen home used for the comparison contained 22 workspaces.
|
||||
|
||||
### Before/After
|
||||
|
||||
| run | code shape | client shape | git commands | failures | elapsed |
|
||||
| ----------- | -------------------------- | ----------------------------------------- | -----------: | -------: | ------: |
|
||||
| baseline | before change | legacy sidebar PR fanout | 529 | 20 | 39039ms |
|
||||
| split check | after change | legacy sidebar PR fanout | 375 | 15 | 39039ms |
|
||||
| after | after change | snapshot-only sidebar, no PR badge fanout | 372 | 15 | 31273ms |
|
||||
| after 2 | after service fact sharing | snapshot-only sidebar, no PR badge fanout | 308 | 15 | 31334ms |
|
||||
|
||||
The server-side fact reuse accounts for nearly all measured git command reduction: `529 -> 375` (`-154`, `-29.1%`) even when the old PR fanout is still forced. Removing the sidebar fanout removes the ad hoc request path, but in this run it only changed command count by `3` because the refreshed workspace snapshots already carried the PR data by the time the fanout ran.
|
||||
|
||||
The second pass shares checkout facts between workspace observation setup and snapshot refresh. That removes another `64` git commands from the same frozen-home run: `372 -> 308` (`-17.2%` from the previous after, `-41.8%` from baseline).
|
||||
|
||||
### Baseline: before change + legacy PR fanout
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": "legacyPrFanout",
|
||||
"workspaceCount": 22,
|
||||
"elapsedMs": 39039,
|
||||
"git": {
|
||||
"total": 529,
|
||||
"failed": 20,
|
||||
"maxConcurrent": 8,
|
||||
"byCommand": [
|
||||
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 66 },
|
||||
{ "key": "rev-parse --git-common-dir", "count": 58 },
|
||||
{ "key": "rev-parse --abbrev-ref HEAD", "count": 50 },
|
||||
{ "key": "rev-parse --git-dir", "count": 36 },
|
||||
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 35 },
|
||||
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 35 },
|
||||
{ "key": "config --get remote.origin.url", "count": 32 },
|
||||
{ "key": "ls-files --others --exclude-standard", "count": 18 },
|
||||
{ "key": "rev-parse --absolute-git-dir", "count": 18 },
|
||||
{ "key": "merge-base HEAD origin/main", "count": 17 },
|
||||
{ "key": "rev-parse --show-toplevel", "count": 14 },
|
||||
{ "key": "status --porcelain", "count": 14 }
|
||||
]
|
||||
},
|
||||
"process": {
|
||||
"cpuUserMs": 2009,
|
||||
"cpuSystemMs": 2428,
|
||||
"rssDeltaMb": -1.5,
|
||||
"heapUsedDeltaMb": 16.9
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### After: after change + snapshot-only sidebar
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": "snapshotOnly",
|
||||
"workspaceCount": 22,
|
||||
"elapsedMs": 31273,
|
||||
"git": {
|
||||
"total": 372,
|
||||
"failed": 15,
|
||||
"maxConcurrent": 8,
|
||||
"byCommand": [
|
||||
{ "key": "config --get remote.origin.url", "count": 35 },
|
||||
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 34 },
|
||||
{ "key": "rev-parse --git-common-dir", "count": 31 },
|
||||
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 22 },
|
||||
{ "key": "status --porcelain", "count": 22 },
|
||||
{ "key": "ls-files --others --exclude-standard", "count": 18 },
|
||||
{ "key": "rev-parse --absolute-git-dir", "count": 18 },
|
||||
{ "key": "merge-base HEAD origin/main", "count": 17 },
|
||||
{ "key": "rev-parse --abbrev-ref HEAD", "count": 17 },
|
||||
{ "key": "rev-parse --show-toplevel", "count": 17 },
|
||||
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 17 },
|
||||
{ "key": "rev-list --count main..origin/main", "count": 7 }
|
||||
]
|
||||
},
|
||||
"process": {
|
||||
"cpuUserMs": 1871,
|
||||
"cpuSystemMs": 2152,
|
||||
"rssDeltaMb": 4.4,
|
||||
"heapUsedDeltaMb": 8.8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### After 2: shared service-level facts
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": "snapshotOnly",
|
||||
"workspaceCount": 22,
|
||||
"elapsedMs": 31334,
|
||||
"git": {
|
||||
"total": 308,
|
||||
"failed": 15,
|
||||
"maxConcurrent": 8,
|
||||
"byCommand": [
|
||||
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 31 },
|
||||
{ "key": "rev-parse --git-common-dir", "count": 26 },
|
||||
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 22 },
|
||||
{ "key": "ls-files --others --exclude-standard", "count": 18 },
|
||||
{ "key": "status --porcelain", "count": 18 },
|
||||
{ "key": "merge-base HEAD origin/main", "count": 17 },
|
||||
{ "key": "config --get remote.origin.url", "count": 13 },
|
||||
{ "key": "rev-parse --abbrev-ref HEAD", "count": 13 },
|
||||
{ "key": "rev-parse --absolute-git-dir", "count": 13 },
|
||||
{ "key": "rev-parse --show-toplevel", "count": 13 },
|
||||
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 13 },
|
||||
{ "key": "fetch origin --prune", "count": 5 }
|
||||
]
|
||||
},
|
||||
"process": {
|
||||
"cpuUserMs": 1817,
|
||||
"cpuSystemMs": 1869,
|
||||
"rssDeltaMb": 16.7,
|
||||
"heapUsedDeltaMb": 10.4
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Snapshot Equivalence Guard
|
||||
|
||||
Added a focused utility test proving that status, shortstat, and PR status return the same data when run from shared snapshot facts. The same test records git calls and asserts the facts-backed path does not re-run:
|
||||
|
||||
- `rev-parse --show-toplevel`
|
||||
- `rev-parse --abbrev-ref HEAD`
|
||||
|
||||
Test:
|
||||
|
||||
`packages/server/src/utils/checkout-git.test.ts` -> `reuses checkout snapshot facts across status, shortstat, and PR status reads`
|
||||
|
||||
## Remaining Waste Visible In Baseline
|
||||
|
||||
This pass reshaped the data flow and removed the sidebar PR badge special path. It did not try to optimize every command.
|
||||
|
||||
The benchmark still shows repeated per-workspace reads that are candidates for the next pass:
|
||||
|
||||
- base ref existence checks still repeat as `show-ref` probes.
|
||||
- default branch resolution still repeats `symbolic-ref refs/remotes/origin/HEAD`.
|
||||
- repo common-dir lookup is lower, but still above the apparent git workspace count.
|
||||
- shortstat still runs its own merge-base/diff/untracked scan per workspace.
|
||||
|
||||
The important invariant now is clearer: sidebar-visible git data should flow from `WorkspaceGitService` snapshots, and snapshot builders should receive reusable git facts through `CheckoutContext`.
|
||||
@@ -0,0 +1,389 @@
|
||||
# OpenCode Provider Snapshot Startup Timeout Diagnosis - 2026-05-27
|
||||
|
||||
## Answer
|
||||
|
||||
The startup timeout is real OpenCode provider snapshot work, not an agent resume path.
|
||||
|
||||
In the dev-style copied-home reproduction, the OpenCode snapshot misses the 30s budget because several expensive things stack:
|
||||
|
||||
1. Paseo starts from a copied `PASEO_HOME` containing 4,851 agent records.
|
||||
2. Clients ask for provider snapshots for three cwd scopes at almost the same time:
|
||||
- `/Users/moboudra`
|
||||
- `/Users/moboudra/dev/paseo`
|
||||
- `/Users/moboudra/dev/blankpage/editor`
|
||||
3. Each OpenCode snapshot runs two OpenCode SDK calls:
|
||||
- `GET /provider?directory=...` through `client.provider.list()`
|
||||
- `GET /agent?directory=...` through `client.app.agents()`
|
||||
4. One cold `opencode serve` process is shared by the three cwd scopes. It took 8.562s to become ready.
|
||||
5. After OpenCode was listening, Paseo issued six OpenCode HTTP calls concurrently.
|
||||
6. The OpenCode `/provider` responses are large: about 3,549,620 decompressed bytes per cwd.
|
||||
7. During the same window, the daemon was still doing heavy startup workspace git work. In the exact 18:14:19-18:14:43 window, the daemon log has 292 git spawn/close events.
|
||||
8. The `/provider` calls eventually succeeded, but too late: they completed about 32.2s-32.5s after the snapshot fetch started, while the snapshot timeout is 30s.
|
||||
|
||||
So the root cause is:
|
||||
|
||||
```text
|
||||
Cold OpenCode server startup + three concurrent cwd snapshots + large OpenCode /provider responses + daemon startup git contention causes client.provider.list() to complete after Paseo's 30s snapshot budget.
|
||||
```
|
||||
|
||||
More precise wording: the contention is machine-level process/CPU/filesystem contention created by daemon startup work, especially git work. It is not proven to be an OpenCode internal lock or a Paseo-only event-loop issue. A daemon-free repro with only OpenCode plus an external git storm slowed the same six OpenCode calls from about 1s to about 30s total.
|
||||
|
||||
Manual settings refresh works because it runs after startup contention is gone and uses `force: true`, which creates fresh OpenCode runtime/server state. The same OpenCode provider refreshes then complete in about 1.7s-2.2s.
|
||||
|
||||
The daemon does not auto-retry error snapshots. A failed provider snapshot is cached as `status: "error"` until an explicit refresh resets it to loading.
|
||||
|
||||
## Follow-up: Normal Copied-Home Startup Check
|
||||
|
||||
I later reran a normal dev-daemon startup against a fresh copy of the same Paseo home metadata and drove the app startup request path:
|
||||
|
||||
```text
|
||||
fetchWorkspaces
|
||||
fetchAgents
|
||||
getProvidersSnapshot(home scope)
|
||||
getProvidersSnapshot(first workspace scope)
|
||||
```
|
||||
|
||||
That run did not reproduce the 30s OpenCode timeout.
|
||||
|
||||
```text
|
||||
home scope:
|
||||
OpenCode ready at ~8s
|
||||
availability: 1.6s
|
||||
fetch total: 5.2s
|
||||
|
||||
first workspace scope:
|
||||
OpenCode ready at ~26s
|
||||
availability: 2.0s
|
||||
fetch total: 15.4s
|
||||
```
|
||||
|
||||
The slowest OpenCode operation in that successful run was the workspace-scoped `/provider` response body read: `13.6s`. The daemon log had no `Timed out refreshing OpenCode` entry and no OpenCode provider snapshot failure.
|
||||
|
||||
This means the timeout is reproducible under the heavier multi-scope startup contention captured below, but it is not guaranteed on every copied-home dev startup.
|
||||
|
||||
## Reproduction Used
|
||||
|
||||
The user's correction was right: the useful reproduction is not a random isolated home. It must match `dev.sh` worktree behavior.
|
||||
|
||||
Relevant scripts:
|
||||
|
||||
- `scripts/dev.sh`
|
||||
- `scripts/dev-daemon.sh`
|
||||
- `scripts/dev-home.sh`
|
||||
|
||||
`dev-home.sh` only seeds this metadata into the dev home:
|
||||
|
||||
```text
|
||||
agents/**/*.json
|
||||
projects/**/*.json
|
||||
config.json
|
||||
```
|
||||
|
||||
It does not copy `chat`, `loops`, `schedules`, sockets, pid files, logs, or worktree contents.
|
||||
|
||||
I ran a separate daemon, not the main daemon:
|
||||
|
||||
```text
|
||||
PASEO_HOME=/var/folders/xl/kkk9drfd3ms_t8x7rmy4z6900000gn/T/paseo-devseed.Wms6pi
|
||||
PASEO_LISTEN=127.0.0.1:51116
|
||||
PASEO_LOG_LEVEL=trace
|
||||
```
|
||||
|
||||
Startup facts:
|
||||
|
||||
```text
|
||||
18:13:39.552 Agent storage initialized: 712ms
|
||||
18:13:39.559 Workspace registries bootstrapped: 719ms
|
||||
18:13:39.961 Agent registry loaded: 4851 records
|
||||
18:13:39.972 Server listening: http://127.0.0.1:51116
|
||||
```
|
||||
|
||||
The probe then connected four client sessions and requested:
|
||||
|
||||
- workspaces
|
||||
- active agents
|
||||
- provider snapshots for home, paseo, and blankpage/editor
|
||||
|
||||
Client-visible result:
|
||||
|
||||
```text
|
||||
18:14:30.263 /Users/moboudra/dev/blankpage/editor opencode error:
|
||||
OpenCode app.agents timed out after 10s
|
||||
|
||||
18:14:41.687 /Users/moboudra/dev/paseo opencode error:
|
||||
Timed out refreshing OpenCode after 30000ms
|
||||
|
||||
18:14:41.688 /Users/moboudra opencode error:
|
||||
Timed out refreshing OpenCode after 30000ms
|
||||
```
|
||||
|
||||
## Exact OpenCode Timeline
|
||||
|
||||
OpenCode snapshot requests began at `18:14:10`.
|
||||
|
||||
Availability checks:
|
||||
|
||||
```text
|
||||
18:14:10.780 opencode availability start for /Users/moboudra
|
||||
18:14:10.787 opencode availability start for /Users/moboudra/dev/paseo
|
||||
18:14:10.800 opencode availability start for /Users/moboudra/dev/blankpage/editor
|
||||
|
||||
18:14:11.363 paseo availability complete: 576ms
|
||||
18:14:11.376 home availability complete: 597ms
|
||||
18:14:11.391 blankpage availability complete: 591ms
|
||||
```
|
||||
|
||||
OpenCode server acquisition:
|
||||
|
||||
```text
|
||||
18:14:11.364 OpenCode server spawn start: opencode serve --port 56376
|
||||
18:14:19.926 OpenCode server listening after 8562ms
|
||||
```
|
||||
|
||||
Six SDK calls were then issued:
|
||||
|
||||
```text
|
||||
18:14:19.931 GET /provider directory=/Users/moboudra/dev/paseo
|
||||
18:14:19.931 GET /agent directory=/Users/moboudra/dev/paseo
|
||||
18:14:19.931 GET /provider directory=/Users/moboudra
|
||||
18:14:19.931 GET /agent directory=/Users/moboudra
|
||||
18:14:19.931 GET /provider directory=/Users/moboudra/dev/blankpage/editor
|
||||
18:14:19.936 GET /agent directory=/Users/moboudra/dev/blankpage/editor
|
||||
```
|
||||
|
||||
Why six:
|
||||
|
||||
| Cwd | Why that scope exists | Model call | Mode call |
|
||||
| -------------------------------------- | ---------------------------------------------------------- | --------------------------------------- | --------------------------------- |
|
||||
| `/Users/moboudra` | home/settings provider snapshot | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
|
||||
| `/Users/moboudra/dev/paseo` | workspace-scoped provider snapshot for the Paseo workspace | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
|
||||
| `/Users/moboudra/dev/blankpage/editor` | workspace/agent cwd snapshot for blankpage/editor | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
|
||||
|
||||
Multiple clients can request the same snapshot scope during startup, but non-forced provider loads are deduped by `(cwd, provider)`. Different cwd scopes are separate loads. Three cwd scopes times two OpenCode SDK calls each is the six OpenCode calls in this repro.
|
||||
|
||||
Headers arrived before the 30s timeout:
|
||||
|
||||
| Call | Cwd | Headers after request |
|
||||
| ----------- | ------------------------------- | --------------------- |
|
||||
| `/provider` | `/Users/moboudra` | 6.462s |
|
||||
| `/agent` | `/Users/moboudra` | 6.681s |
|
||||
| `/agent` | `/Users/moboudra/dev/paseo` | 6.681s |
|
||||
| `/provider` | `/Users/moboudra/dev/paseo` | 8.192s |
|
||||
| `/provider` | `/Users/moboudra/dev/blankpage` | 8.654s |
|
||||
| `/agent` | `/Users/moboudra/dev/blankpage` | 8.649s |
|
||||
|
||||
But body consumption and completion lagged:
|
||||
|
||||
```text
|
||||
18:14:29.380 /agent home complete, total app.agents duration 9450ms
|
||||
18:14:29.813 /agent paseo complete, total app.agents duration 9883ms
|
||||
18:14:30.263 /agent blankpage timed out at 10s
|
||||
18:14:31.332 /agent blankpage body finally finished, after the 10s app.agents timeout
|
||||
|
||||
18:14:41.687 paseo snapshot outer 30s timeout fires
|
||||
18:14:41.688 home snapshot outer 30s timeout fires
|
||||
|
||||
18:14:43.593 /provider home completes, provider.list duration 23664ms, total listModels 32218ms
|
||||
18:14:43.798 /provider blankpage completes, provider.list duration 23868ms, total listModels 32411ms
|
||||
18:14:43.839 /provider paseo completes, provider.list duration 23911ms, total listModels 32476ms
|
||||
```
|
||||
|
||||
The useful `/provider` results arrived about 1.9s-2.2s after the snapshot manager had already marked home and paseo as failed.
|
||||
|
||||
## Why Settings Refresh Works
|
||||
|
||||
After the daemon settled, I ran the same refresh path through the daemon on port `51116`, using `refreshProvidersSnapshot({ providers: ["opencode"] })`.
|
||||
|
||||
Results:
|
||||
|
||||
```text
|
||||
home refresh:
|
||||
total: 2165ms
|
||||
status: ready
|
||||
models: 409
|
||||
modes: 5
|
||||
|
||||
/Users/moboudra/dev/paseo refresh:
|
||||
total: 1675ms
|
||||
status: ready
|
||||
models: 409
|
||||
modes: 5
|
||||
|
||||
/Users/moboudra/dev/blankpage/editor refresh:
|
||||
total: 1794ms
|
||||
status: ready
|
||||
models: 409
|
||||
modes: 5
|
||||
```
|
||||
|
||||
Trace details for the manual-style refresh:
|
||||
|
||||
```text
|
||||
OpenCode server acquisition: 708ms-1291ms
|
||||
/agent completion: 433ms-592ms after request start
|
||||
/provider completion: 524ms-618ms after request start
|
||||
```
|
||||
|
||||
That proves the startup failure is not bad credentials, not a permanently wedged OpenCode install, and not OpenCode generally taking more than 30s. It is startup timing and contention.
|
||||
|
||||
## Minimal OpenCode-Only Repros
|
||||
|
||||
### OpenCode Only, No Daemon, No Artificial Load
|
||||
|
||||
I started a fresh `opencode serve`, waited for stdout `listening on`, then issued the same six HTTP calls concurrently:
|
||||
|
||||
```text
|
||||
GET /provider?directory=/Users/moboudra
|
||||
GET /agent?directory=/Users/moboudra
|
||||
GET /provider?directory=/Users/moboudra/dev/paseo
|
||||
GET /agent?directory=/Users/moboudra/dev/paseo
|
||||
GET /provider?directory=/Users/moboudra/dev/blankpage/editor
|
||||
GET /agent?directory=/Users/moboudra/dev/blankpage/editor
|
||||
```
|
||||
|
||||
Three runs:
|
||||
|
||||
| Run | `opencode serve` ready | All six calls complete |
|
||||
| --- | ---------------------- | ---------------------- |
|
||||
| 1 | 1376ms | 1295ms |
|
||||
| 2 | 906ms | 1050ms |
|
||||
| 3 | 939ms | 898ms |
|
||||
|
||||
Slowest individual call in those runs:
|
||||
|
||||
```text
|
||||
/provider /Users/moboudra/dev/paseo: 1270ms total
|
||||
/agent /Users/moboudra/dev/blankpage/editor: 1251ms total
|
||||
```
|
||||
|
||||
So six concurrent OpenCode calls alone are not the bug.
|
||||
|
||||
### OpenCode Only Plus External Git Storm, No Daemon
|
||||
|
||||
I then ran the same OpenCode-only six-call test while an external shell spawned repeated git commands across the same real workspaces/worktrees. This did not use the Paseo daemon.
|
||||
|
||||
Result:
|
||||
|
||||
```text
|
||||
opencode serve ready: 15479ms
|
||||
all six OpenCode calls complete: 15176ms after server ready
|
||||
combined cold-start + calls: about 30655ms
|
||||
```
|
||||
|
||||
Individual calls under the external git storm:
|
||||
|
||||
| Call | Cwd | Total |
|
||||
| ----------- | -------------------------------------- | ------: |
|
||||
| `/provider` | `/Users/moboudra` | 10684ms |
|
||||
| `/agent` | `/Users/moboudra` | 10767ms |
|
||||
| `/provider` | `/Users/moboudra/dev/paseo` | 13220ms |
|
||||
| `/agent` | `/Users/moboudra/dev/paseo` | 13147ms |
|
||||
| `/provider` | `/Users/moboudra/dev/blankpage/editor` | 14675ms |
|
||||
| `/agent` | `/Users/moboudra/dev/blankpage/editor` | 15038ms |
|
||||
|
||||
This is the daemon-free minimal evidence that process/filesystem contention can push the same OpenCode cold-start + six-call workload to the same 30s boundary.
|
||||
|
||||
## Why It Does Not Retry
|
||||
|
||||
`ProviderSnapshotManager.getSnapshot()` only starts background warmup for:
|
||||
|
||||
- no existing snapshot
|
||||
- missing providers
|
||||
- entries still in `loading` with no active load
|
||||
|
||||
When refresh fails, `refreshProvider()` stores:
|
||||
|
||||
```text
|
||||
status: "error"
|
||||
error: "Timed out refreshing OpenCode after 30000ms"
|
||||
```
|
||||
|
||||
An `error` entry is not treated as stale/loading by `getSnapshot()`, so normal reads keep returning the cached error.
|
||||
|
||||
Settings refresh calls `refresh_providers_snapshot_request`, which routes to:
|
||||
|
||||
```text
|
||||
refreshSettingsSnapshot()
|
||||
clearCachedProviders()
|
||||
resetSnapshotToLoading()
|
||||
refreshProviders(... force: true)
|
||||
```
|
||||
|
||||
That is why you have to force a manual refresh.
|
||||
|
||||
## Git Work During The Repro
|
||||
|
||||
This is not the final optimization report, but it matters for the timeout because it overlaps exactly with OpenCode response handling.
|
||||
|
||||
Total git commands in the dev-style copied-home daemon log:
|
||||
|
||||
```text
|
||||
632 spawned
|
||||
632 closed
|
||||
```
|
||||
|
||||
Top cwd counts:
|
||||
|
||||
| Count | Cwd |
|
||||
| ----: | ------------------------------------------------------------------------------------- |
|
||||
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/merry-ladybug` |
|
||||
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/hopeful-eel` |
|
||||
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-compaction-cancel-loading` |
|
||||
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-archive-worktree-session-history` |
|
||||
| 44 | `/Users/moboudra/.paseo/worktrees/0vpo9h4b/breezy-toad` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/steering-policy-refactor-detached` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/integration-session-mcp-command-stack` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-provider-diagnostic-binary-resolution` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/feat-voice-runtime-on-demand` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/feat-find-in-pane` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/epic-paseo-client-sdk` |
|
||||
| 24 | `/Users/moboudra/dev/paseo` |
|
||||
| 24 | `/Users/moboudra/dev/blankpage/editor` |
|
||||
| 24 | `/Users/moboudra/dev/faro/main` |
|
||||
| 24 | `/Users/moboudra/dev/konbert/web` |
|
||||
| 24 | `/Users/moboudra/dev/paseo-cloud` |
|
||||
|
||||
In the exact OpenCode pressure window, `18:14:19` through `18:14:43`, there were:
|
||||
|
||||
```text
|
||||
142 git command spawns
|
||||
150 git command closes
|
||||
```
|
||||
|
||||
The main repeated command shapes were:
|
||||
|
||||
```text
|
||||
76 git rev-parse --show-toplevel
|
||||
72 git status --porcelain
|
||||
72 git show-ref --verify --quiet refs/remotes/origin/main
|
||||
72 git show-ref --verify --quiet refs/heads/main
|
||||
16 git config --get branch.main.remote
|
||||
16 git config --get branch.main.merge
|
||||
16 git rev-list --count main..origin/main
|
||||
16 git rev-list --count origin/main..main
|
||||
```
|
||||
|
||||
## Original `log.txt` Alignment
|
||||
|
||||
The original startup showed the same home and paseo outer timeout shape:
|
||||
|
||||
```text
|
||||
16:04:22.466 /Users/moboudra/dev/paseo:
|
||||
Timed out refreshing OpenCode after 30000ms
|
||||
|
||||
16:04:22.482 /Users/moboudra:
|
||||
Timed out refreshing OpenCode after 30000ms
|
||||
```
|
||||
|
||||
The original logs did not include SDK fetch/header/body timing, so they could only show the wrapper-level timeout. The dev-style copied-home reproduction with instrumentation now shows the missing link: the `/provider` calls completed just after the 30s snapshot budget.
|
||||
|
||||
## Files Instrumented For Diagnosis
|
||||
|
||||
Temporary trace instrumentation was added to:
|
||||
|
||||
- `packages/server/src/server/agent/provider-snapshot-manager.ts`
|
||||
- `packages/server/src/server/agent/providers/opencode-agent.ts`
|
||||
- `packages/server/src/server/agent/providers/opencode/runtime.ts`
|
||||
- `packages/server/src/server/agent/providers/opencode/server-manager.ts`
|
||||
|
||||
The instrumentation is behavior-neutral and only emits trace logs.
|
||||
381
docs/diagnostics/startup-sequence-analysis-2026-05-27.md
Normal file
381
docs/diagnostics/startup-sequence-analysis-2026-05-27.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# Daemon Startup Sequence Analysis - 2026-05-27
|
||||
|
||||
Source log: `log.txt` at repository root.
|
||||
|
||||
Scope: current sliced startup log, starting at daemon worker startup and ending after workspace registry reconciliation and the first OpenCode heartbeat.
|
||||
|
||||
This report is descriptive only. It does not propose optimizations.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The daemon becomes ready quickly, then does a heavy post-listen startup pass driven by reconnecting clients and workspace/app hydration.
|
||||
|
||||
- Worker start: `16:03:46.678`, line 1.
|
||||
- Server listening: `16:03:48.285`, line 47, elapsed `602ms`.
|
||||
- First client hello: `16:03:50.285`, line 66.
|
||||
- Workspace registries reconciled: `16:04:33.666`, line 1777, elapsed `45983ms`.
|
||||
|
||||
The startup shape is therefore:
|
||||
|
||||
- Daemon listen readiness: about `0.6s`.
|
||||
- Client reconnect plus workspace/app/provider hydration: about `45s`.
|
||||
- No git commands after workspace registry reconciliation in this slice.
|
||||
|
||||
## Method
|
||||
|
||||
I parsed structured trace lines from `log.txt`, especially:
|
||||
|
||||
- `Git command closed`
|
||||
- `agent.session.inbound`
|
||||
- `agent.session.outbound`
|
||||
- `ws_slow_request`
|
||||
- provider snapshot warnings
|
||||
- provider resume events
|
||||
|
||||
Important limitation: git command logs do not carry a websocket request id, so per-request attribution is inferred from timing and server code paths. Per-workspace git counts, command shapes, durations, and failures are exact for this log.
|
||||
|
||||
Relevant code paths checked:
|
||||
|
||||
- `packages/server/src/server/session.ts`
|
||||
- `fetch_workspaces_request` calls `syncWorkspaceGitObservers(payload.entries)`.
|
||||
- `checkout_status_request` calls `workspaceGitService.getSnapshot(resolvedCwd)`.
|
||||
- `checkout_pr_status_request` calls `workspaceGitService.getSnapshot(cwd)`.
|
||||
- `packages/server/src/server/workspace-git-service.ts`
|
||||
- checkout snapshot/root resolution uses `git rev-parse --show-toplevel`.
|
||||
- snapshot refresh collects dirty state, upstream/ahead/behind, ref existence, and base divergence.
|
||||
- `packages/app/src/contexts/session-context.tsx`
|
||||
- initial workspace hydration calls `client.fetchWorkspaces({ sort: activity_at desc, subscribe, page limit 200 })`.
|
||||
- `packages/app/src/hooks/use-sidebar-workspaces-list.ts`
|
||||
- sidebar workspace refresh also calls `client.fetchWorkspaces({ sort: activity_at desc, page limit 200 })`.
|
||||
|
||||
## Startup Timeline
|
||||
|
||||
| time | line | event |
|
||||
| -------------- | ---: | ------------------------------------------------------------------ |
|
||||
| `16:03:46.678` | 1 | `DaemonRunner` starts daemon worker |
|
||||
| `16:03:47.683` | 4 | worker spawned |
|
||||
| `16:03:47.684` | 6 | daemon keypair loaded |
|
||||
| `16:03:48.281` | 44 | bootstrap complete, ready to listen |
|
||||
| `16:03:48.285` | 47 | server listening on `0.0.0.0:6767` |
|
||||
| `16:03:50.274` | 60 | first websocket awaiting hello |
|
||||
| `16:03:50.285` | 66 | first client connected via hello |
|
||||
| `16:04:22.466` | 987 | OpenCode provider snapshot timeout for `/Users/moboudra/dev/paseo` |
|
||||
| `16:04:22.482` | 1002 | OpenCode provider snapshot timeout for `/Users/moboudra` |
|
||||
| `16:04:24.183` | 1201 | OpenCode provider subscribe starts |
|
||||
| `16:04:24.183` | 1202 | OpenCode provider subscribe ready |
|
||||
| `16:04:24.306` | 1214 | OpenCode server connected event |
|
||||
| `16:04:25.933` | 1321 | OpenCode agent resumed from persistence |
|
||||
| `16:04:33.666` | 1777 | workspace registries reconciled |
|
||||
| `16:04:34.197` | 1783 | OpenCode heartbeat |
|
||||
| `16:04:44.200` | 1789 | OpenCode heartbeat |
|
||||
|
||||
## Git Command Totals
|
||||
|
||||
Total git commands in the sliced startup: `444`.
|
||||
|
||||
| phase | commands | failures | summed process time |
|
||||
| ------------------------------- | -------: | -------: | ------------------: |
|
||||
| daemon bootstrap before listen | 13 | 4 | 445ms |
|
||||
| post-listen before first client | 1 | 0 | 2020ms |
|
||||
| client reconnect + reconcile | 430 | 71 | 120813ms |
|
||||
| after reconcile | 0 | 0 | 0ms |
|
||||
| total | 444 | 75 | 123278ms |
|
||||
|
||||
Summed process time is not wall-clock time. Many commands overlap.
|
||||
|
||||
## Git Command Categories
|
||||
|
||||
| category | commands | failures | summed process time | max duration |
|
||||
| ---------------------------------------------------- | -------: | -------: | ------------------: | -----------: |
|
||||
| ahead/behind: `rev-list --count ...` | 115 | 30 | 35815ms | 1557ms |
|
||||
| refs: `show-ref --verify --quiet ...` | 86 | 2 | 14680ms | 1303ms |
|
||||
| upstream config: `config --get branch.*` | 85 | 13 | 26437ms | 1624ms |
|
||||
| root detection: `rev-parse --show-toplevel` | 80 | 30 | 24164ms | 1426ms |
|
||||
| dirty status: `status --porcelain` | 50 | 0 | 12670ms | 2020ms |
|
||||
| base divergence: `rev-list --left-right --count ...` | 28 | 0 | 9512ms | 1085ms |
|
||||
|
||||
What those categories mean in the app:
|
||||
|
||||
- Root detection: determine whether a cwd is inside a git repo and find its checkout root.
|
||||
- Dirty status: show dirty/clean workspace state.
|
||||
- Upstream config and ahead/behind: show branch tracking and sync state.
|
||||
- Ref existence and base divergence: compare checkout branch against candidate base refs for checkout/PR status.
|
||||
|
||||
## Per-Workspace Git Work
|
||||
|
||||
Columns:
|
||||
|
||||
- `phase`: `pre/warm/reconnect/after`
|
||||
- `cats`: `root/dirty/upstream/ahead/refs/base/other`
|
||||
- `total_ms`: summed process time for that workspace
|
||||
|
||||
| workspace | cmds | fail | phase | cats | total_ms | max_ms | window | failing command shapes |
|
||||
| ----------------------------------------------------------------------- | ---: | ---: | ---------- | ----------------- | -------: | -----: | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `~/.paseo/worktrees/1luy0po7/fix-compaction-cancel-loading` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 8255 | 1460 | `16:03:52.908-16:04:24.019` | `3x config --get branch.fix-compaction-cancel-loading.remote`; `3x rev-list --count fix-compaction-cancel-loading..origin/fix-compaction-cancel-loading`; `3x rev-list --count origin/fix-compaction-cancel-loading..fix-compaction-cancel-loading` |
|
||||
| `~/.paseo/worktrees/1luy0po7/hopeful-eel` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 8468 | 1544 | `16:03:53.245-16:04:27.334` | `3x config --get branch.feat/markdown-annotations.remote`; `3x rev-list --count feat/markdown-annotations..origin/feat/markdown-annotations`; `3x rev-list --count origin/feat/markdown-annotations..feat/markdown-annotations` |
|
||||
| `~/.paseo/worktrees/1luy0po7/merry-ladybug` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 7154 | 1099 | `16:03:53.696-16:04:29.644` | `3x config --get branch.feat/mcp-configuration.remote`; `3x rev-list --count feat/mcp-configuration..origin/feat/mcp-configuration`; `3x rev-list --count origin/feat/mcp-configuration..feat/mcp-configuration` |
|
||||
| `~/dev/paseo` | 30 | 0 | `2/0/28/0` | `5/5/10/10/0/0/0` | 7457 | 1624 | `16:03:48.171-16:04:27.284` | |
|
||||
| `~/.paseo/worktrees/0vpo9h4b/dazzling-duck` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7617 | 1269 | `16:03:51.918-16:04:23.971` | |
|
||||
| `~/.paseo/worktrees/1luy0po7/epic-paseo-client-sdk` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7428 | 1426 | `16:03:52.445-16:04:23.991` | |
|
||||
| `~/.paseo/worktrees/1luy0po7/fix-provider-diagnostic-binary-resolution` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7764 | 1091 | `16:03:52.681-16:04:23.971` | |
|
||||
| `~/dev/emdash` | 22 | 6 | `0/0/22/0` | `2/2/2/6/8/2/0` | 3031 | 453 | `16:04:27.351-16:04:29.583` | `2x config --get branch.heads/main.remote`; `2x rev-list --count heads/main..origin/heads/main`; `2x rev-list --count origin/heads/main..heads/main` |
|
||||
| `~/dev/opencode` | 22 | 4 | `0/0/22/0` | `2/2/2/6/8/2/0` | 2279 | 313 | `16:04:24.058-16:04:24.970` | `2x rev-list --count ecosystem-paseo..origin/ecosystem-paseo`; `2x rev-list --count origin/ecosystem-paseo..ecosystem-paseo` |
|
||||
| `~/.paseo/worktrees/1luy0po7/integration-session-mcp-command-stack` | 18 | 0 | `0/0/18/0` | `3/3/6/6/0/0/0` | 7467 | 1242 | `16:03:53.781-16:04:23.971` | |
|
||||
| `~/dev/blankpage/editor` | 18 | 0 | `2/0/16/0` | `3/3/6/6/0/0/0` | 2418 | 520 | `16:03:48.174-16:04:26.411` | |
|
||||
| `~/dev/konbert/web` | 18 | 0 | `1/1/16/0` | `3/3/6/6/0/0/0` | 7324 | 2020 | `16:03:48.190-16:04:23.685` | |
|
||||
| `~/dev/openchamber` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 1554 | 336 | `16:04:27.399-16:04:29.616` | |
|
||||
| `~/dev/superset` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 1019 | 215 | `16:04:27.341-16:04:29.617` | |
|
||||
| `~/dev/t3code` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 2761 | 588 | `16:04:27.356-16:04:29.603` | |
|
||||
| `~/.paseo/worktrees/0vpo9h4b/breezy-toad` | 11 | 5 | `0/0/11/0` | `1/1/1/3/4/1/0` | 6465 | 1290 | `16:03:51.307-16:04:18.112` | `1x config --get branch.fix/user-delete-dark-mode.remote`; `1x rev-list --count fix/user-delete-dark-mode..origin/fix/user-delete-dark-mode`; `1x rev-list --count origin/fix/user-delete-dark-mode..fix/user-delete-dark-mode`; `2x show-ref --verify --quiet refs/remotes/origin/my-branch` |
|
||||
| `~/.paseo/worktrees/1luy0po7/fix-archive-worktree-session-history` | 11 | 3 | `0/0/11/0` | `1/1/1/3/4/1/0` | 4303 | 757 | `16:03:52.539-16:04:19.011` | `1x config --get branch.fix-archive-worktree-session-history.remote`; `1x rev-list --count fix-archive-worktree-session-history..origin/fix-archive-worktree-session-history`; `1x rev-list --count origin/fix-archive-worktree-session-history..fix-archive-worktree-session-history` |
|
||||
| `~/.paseo/worktrees/0vpo9h4b/codex-github-mention-implement-db-garbage` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 4986 | 1005 | `16:03:51.261-16:04:16.878` | |
|
||||
| `~/.paseo/worktrees/1luy0po7/feat-find-in-pane` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5273 | 1130 | `16:03:52.391-16:04:17.682` | |
|
||||
| `~/.paseo/worktrees/1luy0po7/feat-voice-runtime-on-demand` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5176 | 839 | `16:03:52.110-16:04:18.254` | |
|
||||
| `~/.paseo/worktrees/steering-policy-refactor-detached` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5993 | 1243 | `16:03:53.984-16:04:17.673` | |
|
||||
| `~/dev/faro/main` | 6 | 0 | `2/0/4/0` | `1/1/2/2/0/0/0` | 4964 | 1603 | `16:03:48.168-16:04:03.748` | |
|
||||
| `~/dev/paseo-cloud` | 6 | 0 | `2/0/4/0` | `1/1/2/2/0/0/0` | 2123 | 1154 | `16:03:48.159-16:03:56.377` | |
|
||||
| `~/dev/assistant` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 85 | 29 | `16:03:48.165-16:04:24.048` | `3x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/review` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 224 | 105 | `16:03:48.197-16:04:26.560` | `3x rev-parse --show-toplevel` |
|
||||
| `~/dev/research/orchestrator-worker` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 285 | 144 | `16:03:48.194-16:04:26.575` | `3x rev-parse --show-toplevel` |
|
||||
| `/tmp` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 113 | 77 | `16:04:27.388-16:04:27.471` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 86 | 58 | `16:04:27.384-16:04:27.457` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/01-claude-opus` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 216 | 185 | `16:04:26.525-16:04:26.543` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/02-codex-gpt55` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 98 | 68 | `16:04:26.353-16:04:26.554` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/03-opencode-zai-glm51` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 209 | 158 | `16:04:26.512-16:04:26.576` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/04-opencode-zen-minimax27` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 148 | 101 | `16:04:26.431-16:04:26.549` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/05-opencode-zen-kimi26` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 231 | 172 | `16:04:26.517-16:04:26.577` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/06-opencode-or-deepseek4pro` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 93 | 73 | `16:04:27.365-16:04:27.380` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/07-opencode-zen-gemini35flash` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 78 | 66 | `16:04:27.363-16:04:27.375` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/08-opencode-zen-gpt55` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 120 | 65 | `16:04:27.359-16:04:27.430` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/assistant/game` | 1 | 1 | `1/0/0/0` | `1/0/0/0/0/0/0` | 13 | 13 | `16:03:48.155-16:03:48.155` | `1x rev-parse --show-toplevel` |
|
||||
|
||||
## Git Failure Shape
|
||||
|
||||
There were 75 nonzero git exits.
|
||||
|
||||
Most failures were not timeouts. They were expected probe failures:
|
||||
|
||||
- Non-repo checks: `rev-parse --show-toplevel` fails for paths that are not git repositories.
|
||||
- Missing upstream config: `config --get branch.<branch>.remote` fails for branches without configured upstream.
|
||||
- Missing remote branch graph: `rev-list --count <branch>..origin/<branch>` fails when the remote branch/ref does not exist.
|
||||
- Missing ref checks: `show-ref --verify --quiet refs/remotes/origin/my-branch` fails when a candidate ref does not exist.
|
||||
|
||||
The `~/dev/opencode` git failures are branch graph probes for `ecosystem-paseo` versus `origin/ecosystem-paseo`, not OpenCode provider startup failures.
|
||||
|
||||
## Inbound Client Work
|
||||
|
||||
Inbound session messages during the startup window:
|
||||
|
||||
| request | count |
|
||||
| --------------------------------- | ----: |
|
||||
| `client_heartbeat` | 19 |
|
||||
| `checkout_pr_status_request` | 18 |
|
||||
| `fetch_agents_request` | 11 |
|
||||
| `fetch_workspaces_request` | 9 |
|
||||
| `get_providers_snapshot_request` | 9 |
|
||||
| `project_icon_request` | 9 |
|
||||
| `fetch_agent_timeline_request` | 7 |
|
||||
| `clear_agent_attention` | 6 |
|
||||
| `list_terminals_request` | 5 |
|
||||
| `subscribe_terminals_request` | 5 |
|
||||
| `list_available_editors_request` | 2 |
|
||||
| `subscribe_checkout_diff_request` | 2 |
|
||||
| `checkout_status_request` | 1 |
|
||||
| `fetch_agent_request` | 1 |
|
||||
| `file_explorer_request` | 1 |
|
||||
| `read_project_config_request` | 1 |
|
||||
| `workspace_setup_status_request` | 1 |
|
||||
|
||||
Inbound by client:
|
||||
|
||||
| client | count | top work |
|
||||
| ----------------------------------------------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Electron `cid_d555...`, origin `http://localhost:8082` | 68 | `checkout_pr_status_request:18`, `project_icon_request:9`, `clear_agent_attention:6`, `fetch_agent_timeline_request:4`, `fetch_workspaces_request:3`, `fetch_agents_request:3`, `get_providers_snapshot_request:3` |
|
||||
| HeadlessChrome `cid_d39...`, origin `http://localhost:8081` | 13 | `client_heartbeat:4`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
|
||||
| local web `cid_a2b...`, origin `http://localhost:6767` | 13 | `client_heartbeat:4`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
|
||||
| Android `cid_24c...`, origin `http://10.0.2.2:6767` | 11 | `client_heartbeat:2`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
|
||||
| `cid_70d...`, host `0.0.0.0:6767` | 2 | `fetch_agents_request:2` |
|
||||
|
||||
## Outbound Client Work
|
||||
|
||||
Outbound session messages during the startup window:
|
||||
|
||||
| message | count |
|
||||
| ---------------------------------- | ----: |
|
||||
| `providers_snapshot_update` | 129 |
|
||||
| `workspace_update` | 81 |
|
||||
| `checkout_status_update` | 76 |
|
||||
| `agent_update` | 47 |
|
||||
| `checkout_pr_status_response` | 18 |
|
||||
| `fetch_agents_response` | 11 |
|
||||
| `fetch_workspaces_response` | 9 |
|
||||
| `get_providers_snapshot_response` | 9 |
|
||||
| `project_icon_response` | 9 |
|
||||
| `fetch_agent_timeline_response` | 7 |
|
||||
| `list_terminals_response` | 5 |
|
||||
| `terminals_changed` | 5 |
|
||||
| `list_available_editors_response` | 2 |
|
||||
| `subscribe_checkout_diff_response` | 2 |
|
||||
| `checkout_status_response` | 1 |
|
||||
| `fetch_agent_response` | 1 |
|
||||
| `file_explorer_response` | 1 |
|
||||
| `read_project_config_response` | 1 |
|
||||
| `workspace_setup_status_response` | 1 |
|
||||
|
||||
Provider snapshot updates were large and repeated:
|
||||
|
||||
- Around lines 982-986: five `providers_snapshot_update` messages, each `215932` bytes.
|
||||
- Around lines 997-1001: five `providers_snapshot_update` messages, each `215898` bytes.
|
||||
- Around lines 1250-1254: five `providers_snapshot_update` messages, each `414735` bytes.
|
||||
|
||||
## Slow Requests
|
||||
|
||||
Slow requests logged during startup:
|
||||
|
||||
| time | request | duration | client | line |
|
||||
| -------------- | --------------------------------: | -------: | --------------------- | ---: |
|
||||
| `16:04:29.702` | `fetch_agent_timeline_request` | 39372ms | HeadlessChrome | 1767 |
|
||||
| `16:04:29.702` | `fetch_agent_timeline_request` | 39212ms | Electron | 1768 |
|
||||
| `16:04:29.702` | `fetch_agent_timeline_request` | 38914ms | local web | 1769 |
|
||||
| `16:04:33.665` | `checkout_pr_status_request` | 20181ms | Electron | 1776 |
|
||||
| `16:04:08.109` | `subscribe_checkout_diff_request` | 17618ms | Electron | 565 |
|
||||
| `16:04:29.702` | `fetch_agent_timeline_request` | 16216ms | Electron | 1770 |
|
||||
| `16:04:06.624` | `fetch_agent_timeline_request` | 16134ms | Electron | 524 |
|
||||
| `16:04:29.396` | `checkout_pr_status_request` | 15911ms | Electron | 1671 |
|
||||
| `16:04:29.256` | `checkout_pr_status_request` | 15772ms | Electron | 1651 |
|
||||
| `16:04:29.149` | `checkout_pr_status_request` | 15665ms | Electron | 1638 |
|
||||
| `16:04:29.054` | `checkout_pr_status_request` | 15569ms | Electron | 1628 |
|
||||
| `16:04:28.932` | `checkout_pr_status_request` | 15448ms | Electron | 1611 |
|
||||
| `16:04:28.809` | `checkout_pr_status_request` | 15324ms | Electron | 1601 |
|
||||
| `16:04:28.672` | `checkout_pr_status_request` | 15188ms | Electron | 1582 |
|
||||
| `16:04:28.555` | `checkout_pr_status_request` | 15071ms | Electron | 1567 |
|
||||
| `16:04:28.421` | `checkout_pr_status_request` | 14936ms | Electron | 1556 |
|
||||
| `16:04:28.323` | `checkout_pr_status_request` | 14839ms | Electron | 1549 |
|
||||
| `16:04:28.324` | `checkout_status_request` | 14839ms | Electron | 1550 |
|
||||
| `16:04:28.189` | `checkout_pr_status_request` | 14705ms | Electron | 1536 |
|
||||
| `16:04:29.634` | `fetch_agents_request` | 14590ms | `0.0.0.0:6767` client | 1759 |
|
||||
| `16:04:28.006` | `checkout_pr_status_request` | 14522ms | Electron | 1526 |
|
||||
| `16:04:27.628` | `checkout_pr_status_request` | 14143ms | Electron | 1496 |
|
||||
| `16:04:27.061` | `checkout_pr_status_request` | 13576ms | Electron | 1405 |
|
||||
| `16:04:29.645` | `fetch_agents_request` | 13384ms | `0.0.0.0:6767` client | 1762 |
|
||||
| `16:04:02.812` | `fetch_agent_timeline_request` | 12321ms | Electron | 440 |
|
||||
| `16:04:25.740` | `checkout_pr_status_request` | 12256ms | Electron | 1309 |
|
||||
| `16:04:25.352` | `checkout_pr_status_request` | 11867ms | Electron | 1296 |
|
||||
| `16:04:04.217` | `fetch_agent_timeline_request` | 11751ms | Android | 462 |
|
||||
| `16:04:25.155` | `checkout_pr_status_request` | 11671ms | Electron | 1284 |
|
||||
| `16:04:23.196` | `fetch_agent_request` | 9711ms | Electron | 1070 |
|
||||
| `16:04:17.563` | `project_icon_request` | 4079ms | Electron | 877 |
|
||||
| `16:03:53.022` | `list_available_editors_request` | 2533ms | Electron | 254 |
|
||||
| `16:04:15.703` | `project_icon_request` | 2218ms | Electron | 824 |
|
||||
| `16:04:15.696` | `project_icon_request` | 2211ms | Electron | 822 |
|
||||
| `16:04:15.694` | `project_icon_request` | 2209ms | Electron | 820 |
|
||||
| `16:04:15.103` | `file_explorer_request` | 1618ms | Electron | 806 |
|
||||
| `16:04:14.107` | `list_terminals_request` | 621ms | Electron | 764 |
|
||||
| `16:03:50.945` | `list_terminals_request` | 614ms | HeadlessChrome | 156 |
|
||||
|
||||
The checkout PR requests are especially clustered: 18 Electron `checkout_pr_status_request` messages arrive together at `16:04:13.484`, lines 699-716. Their slow-request completions drain over the next ~20s, with `inflightRequests` dropping from 20 to 0.
|
||||
|
||||
## Provider Findings
|
||||
|
||||
### OpenCode
|
||||
|
||||
OpenCode provider snapshot refresh had two timeouts:
|
||||
|
||||
| time | line | cwd | error |
|
||||
| -------------- | ---: | --------------------------- | --------------------------------------------- |
|
||||
| `16:04:22.466` | 987 | `/Users/moboudra/dev/paseo` | `Timed out refreshing OpenCode after 30000ms` |
|
||||
| `16:04:22.482` | 1002 | `/Users/moboudra` | `Timed out refreshing OpenCode after 30000ms` |
|
||||
|
||||
These are provider snapshot failures, not OpenCode agent resume failures.
|
||||
|
||||
The persisted OpenCode agent did resume:
|
||||
|
||||
| time | line | event |
|
||||
| -------------- | ---: | ----------------------------------------------------- |
|
||||
| `16:04:24.183` | 1201 | `provider.opencode.subscribe.start` |
|
||||
| `16:04:24.183` | 1202 | `provider.opencode.subscribe.ready` |
|
||||
| `16:04:24.306` | 1214 | raw event `server.connected` |
|
||||
| `16:04:25.933` | 1321 | `Agent resumed from persistence`, provider `opencode` |
|
||||
| `16:04:34.197` | 1783 | raw event `server.heartbeat` |
|
||||
| `16:04:44.200` | 1789 | raw event `server.heartbeat` |
|
||||
|
||||
There are no `provider.opencode.subscribe.error` or OpenCode agent fatal errors in this slice.
|
||||
|
||||
OpenCode-related git:
|
||||
|
||||
- `~/dev/opencode` had 22 git commands.
|
||||
- Four failed.
|
||||
- The failed commands were branch graph probes for `ecosystem-paseo` versus `origin/ecosystem-paseo`.
|
||||
- Those failures are git state/probe failures, not OpenCode provider process failures.
|
||||
|
||||
### Codex
|
||||
|
||||
Codex provider startup observations:
|
||||
|
||||
- `provider.codex.spawn` appears multiple times for provider snapshot/config discovery.
|
||||
- A persisted Codex agent resumes successfully at `16:04:06.357`, line 518.
|
||||
- Debug logs show failed reads of Codex saved config defaults, but these are debug-level and do not become provider startup warnings/errors in this slice.
|
||||
- There are unhandled Codex trace event types such as remote-control/status and thread/goal status, but no Codex timeout or fatal provider startup failure in this slice.
|
||||
|
||||
### Claude
|
||||
|
||||
Claude agents resume successfully:
|
||||
|
||||
| time | line | client | agent |
|
||||
| -------------- | ---: | -------- | -------------------------------------- |
|
||||
| `16:04:02.540` | 434 | Electron | `f884552a-1383-4dba-8583-7ae0b6a62353` |
|
||||
| `16:04:03.772` | 456 | Android | `0c89a057-05f2-4e23-9895-84c8e1952310` |
|
||||
|
||||
## What Work The App Asked For
|
||||
|
||||
The startup work visible in the app/server protocol is:
|
||||
|
||||
- Workspace list/sidebar hydration:
|
||||
- `fetch_workspaces_request`, 9 total.
|
||||
- This asks for the workspace list sorted by `activity_at desc`, usually page limit 200.
|
||||
- On the server this triggers workspace git observer sync and workspace update flushing.
|
||||
|
||||
- Agent list and agent detail hydration:
|
||||
- `fetch_agents_request`, 11 total.
|
||||
- `fetch_agent_request`, 1 total.
|
||||
- `fetch_agent_timeline_request`, 7 total.
|
||||
- Timeline requests are among the slowest requests in this slice.
|
||||
|
||||
- Checkout/PR status UI:
|
||||
- `checkout_pr_status_request`, 18 total, all Electron.
|
||||
- `checkout_status_request`, 1 total.
|
||||
- `subscribe_checkout_diff_request`, 2 total.
|
||||
- These correspond to git snapshot consumers and are clustered during Electron reconnect.
|
||||
|
||||
- Provider/model/mode UI:
|
||||
- `get_providers_snapshot_request`, 9 total.
|
||||
- `providers_snapshot_update`, 129 outbound updates.
|
||||
- OpenCode provider snapshot refresh times out twice during this flow.
|
||||
|
||||
- Workspace chrome:
|
||||
- `project_icon_request`, 9 total.
|
||||
- `file_explorer_request`, 1 total.
|
||||
|
||||
- Terminal panel:
|
||||
- `list_terminals_request`, 5 total.
|
||||
- `subscribe_terminals_request`, 5 total.
|
||||
- `terminals_changed`, 5 outbound updates.
|
||||
|
||||
- Attention state:
|
||||
- `clear_agent_attention`, 6 total.
|
||||
- Some failures appear while clearing attention for persisted agents, but these are not provider startup failures.
|
||||
|
||||
## Concrete Waste-Looking Work, Without Optimizing Yet
|
||||
|
||||
The log shows repeated work in these exact forms:
|
||||
|
||||
- 444 git commands total, but only 14 complete before the first client hello. The rest are post-listen startup/client hydration work.
|
||||
- Several workspaces get repeated full checkout snapshot patterns:
|
||||
- three 33-command worktrees each get `3` root checks, `3` dirty checks, `3` upstream config probes, `9` ahead/behind probes, `12` ref checks, and `3` base divergence checks.
|
||||
- three 27-command worktrees each get `3` root checks, `3` dirty checks, `6` upstream config probes, `6` ahead/behind probes, `6` ref checks, and `3` base divergence checks.
|
||||
- `~/dev/paseo` gets `5` root checks, `5` dirty checks, `10` upstream config probes, and `10` ahead/behind probes.
|
||||
- Electron sends 18 `checkout_pr_status_request` messages at the same timestamp, then they drain slowly over ~20s.
|
||||
- Provider snapshot updates are broadcast very frequently: 129 outbound `providers_snapshot_update` messages, including large repeated payloads around 216KB and 415KB.
|
||||
- OpenCode snapshot refresh times out twice after 30s, but the actual OpenCode agent connection/resume succeeds.
|
||||
|
||||
Again, this section names repeated work observed in the startup. It does not claim which repetition should be removed.
|
||||
@@ -3,24 +3,24 @@
|
||||
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
|
||||
|
||||
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/server/src/shared/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`).
|
||||
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/server/src/shared/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/server/src/shared/messages.ts:1936`), `DaemonClient` (`packages/server/src/client/daemon-client.ts`).
|
||||
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
|
||||
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
|
||||
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
|
||||
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/server/src/shared/messages.ts:2113`).
|
||||
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/server/src/shared/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/server/src/shared/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
|
||||
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/protocol/src/messages.ts:2113`).
|
||||
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
|
||||
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
|
||||
- **Session** — Per-client connection to a daemon. Internal. Code: `Session` (`packages/server/src/server/session.ts`). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:198`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/server/src/shared/messages.ts:187`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/server/src/shared/terminal-stream-protocol.ts`).
|
||||
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/server/src/shared/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
|
||||
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/server/src/shared/messages.ts:257`).
|
||||
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/server/src/shared/messages.ts:782`).
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/protocol/src/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
|
||||
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
|
||||
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
|
||||
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
|
||||
- **Composer input** — The text-entry surface inside the composer. Code: `MessageInput` (`packages/app/src/composer/input/input.tsx`).
|
||||
- **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar".
|
||||
@@ -33,4 +33,4 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
## Inconsistencies (documented, not papered over)
|
||||
|
||||
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
|
||||
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/server/src/shared/messages.ts:2187`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:8`).
|
||||
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/protocol/src/messages.ts:2187`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:8`).
|
||||
|
||||
@@ -24,7 +24,7 @@ Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loade
|
||||
|
||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
|
||||
|
||||
|
||||
@@ -2,6 +2,29 @@
|
||||
|
||||
All workspaces share one version and release together.
|
||||
|
||||
## Two steps
|
||||
|
||||
A release has exactly two steps. The agent does the first, the user authorizes the second.
|
||||
|
||||
**Preparation** (local, reversible — agent does this):
|
||||
|
||||
- format, lint, typecheck all green
|
||||
- draft the changelog, show it to the user, wait for review
|
||||
- run the pre-release sanity check, surface findings to the user
|
||||
- confirm CI is green
|
||||
|
||||
**Go-ahead** (user says "go ahead"):
|
||||
|
||||
- commit the approved changelog
|
||||
- run the release
|
||||
|
||||
Rules that apply to both steps:
|
||||
|
||||
- Last-minute changes always need approval. Every time.
|
||||
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
|
||||
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
|
||||
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
|
||||
|
||||
## Two paths
|
||||
|
||||
There are two supported ways to ship from `main`:
|
||||
|
||||
@@ -113,6 +113,8 @@ Vitest picks up tests by suffix. The suffix tells the runner which category it b
|
||||
|
||||
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
|
||||
|
||||
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
|
||||
|
||||
### Test setup
|
||||
|
||||
- Server: `packages/server/src/test-utils/vitest-setup.ts` loads `.env.test`, sets `PASEO_SUPERVISED=0`, and disables Git/SSH prompts. Add new global env shims here, not in individual tests.
|
||||
@@ -155,3 +157,12 @@ If code isn't testable, refactor it. Signs:
|
||||
- Setup requires too much global state
|
||||
|
||||
Aim for deep modules: small interface, deep implementation. Fewer methods = fewer tests needed, simpler params = simpler setup.
|
||||
|
||||
## Two test categories, no others
|
||||
|
||||
Every test in this repo lives in exactly one of these shapes:
|
||||
|
||||
1. **Unit tests with ports and adapters** — production code receives its real-world dependencies (DB, HTTP, CLI process, clock, randomness, filesystem, other modules) through an injected interface. Tests wire a typed in-memory fake colocated with the production module. **No `vi.mock`, `vi.hoisted`, `vi.spyOn` of own exports, JSDOM, `@testing-library` component mounting, RN test renderer, monkey-patched globals, or fake-server fixtures.** If a test needs any of those, the production module is missing a port — fix the seam, then write the test against a fake adapter.
|
||||
2. **Real end-to-end tests** — real daemon, real network, real browser (Playwright for app code) or a real isolated server instance (for daemon code). No JSDOM, no mocked transport.
|
||||
|
||||
Anything in between — component tests in JSDOM, vitest tests that mock the module under test, tests that assert on private state — is slop on its way out.
|
||||
|
||||
@@ -4,6 +4,7 @@ pre-commit:
|
||||
- name: format
|
||||
glob: "*.{css,js,json,jsonc,jsx,md,ts,tsx,yaml,yml}"
|
||||
exclude:
|
||||
- "package-lock.json"
|
||||
- "**/package-lock.json"
|
||||
run: npm run format:check:files -- {staged_files}
|
||||
- name: lint
|
||||
|
||||
@@ -73,10 +73,10 @@ buildNpmPackage rec {
|
||||
# Native deps (terminal emulation; libuv-linked on Linux)
|
||||
npm rebuild node-pty
|
||||
|
||||
# Daemon workspaces (highlight + relay + server + cli)
|
||||
npm run build:daemon
|
||||
# Server workspaces (highlight + relay + protocol + client + server + cli)
|
||||
npm run build:server
|
||||
|
||||
# App workspace deps not covered by build:daemon
|
||||
# App workspace deps not covered by build:server
|
||||
npm run build --workspace=@getpaseo/expo-two-way-audio
|
||||
|
||||
# Expo web export for the Electron renderer
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-WAxd4FSYNGYPT7v0QYTkYF5AacMrpYU+zjMyMTcWufo=
|
||||
sha256-25V9uu0hSecGCDixaJwQ9wu6Mj0cfG6uXNYjMZIZ58U=
|
||||
|
||||
@@ -79,8 +79,8 @@ buildNpmPackage rec {
|
||||
# degrade when unavailable.
|
||||
npm rebuild node-pty
|
||||
|
||||
# Build all daemon packages in dependency order (defined in package.json)
|
||||
npm run build:daemon
|
||||
# Build all server packages in dependency order (defined in package.json)
|
||||
npm run build:server
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
764
package-lock.json
generated
764
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.83",
|
||||
"version": "0.1.85",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
@@ -24,6 +24,8 @@
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
"packages/protocol",
|
||||
"packages/client",
|
||||
"packages/server",
|
||||
"packages/app",
|
||||
"packages/relay",
|
||||
@@ -34,16 +36,24 @@
|
||||
"scripts": {
|
||||
"dev": "./scripts/dev.sh",
|
||||
"dev:win": "powershell ./scripts/dev.ps1",
|
||||
"dev:server": "npm run dev --workspace=@getpaseo/server",
|
||||
"dev:server": "npm run build:server-deps && concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
|
||||
"dev:server:raw": "npm run dev --workspace=@getpaseo/server",
|
||||
"dev:app": "npm run start --workspace=@getpaseo/app",
|
||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||
"postinstall": "node scripts/postinstall-patches.mjs",
|
||||
"prepare": "lefthook install --force",
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
|
||||
"build:daemon": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||
"build:relay": "npm run build --workspace=@getpaseo/relay",
|
||||
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
|
||||
"build:client": "npm run build:protocol && npm run build --workspace=@getpaseo/client",
|
||||
"build:server-deps": "npm run build:highlight && npm run build:relay && npm run build:client",
|
||||
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
|
||||
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"typecheck:daemon": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"format": "oxfmt .",
|
||||
"format:files": "oxfmt",
|
||||
@@ -63,7 +73,7 @@
|
||||
"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 build:workspace-deps --workspace=@getpaseo/app && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
|
||||
"build:desktop": "npm run build:app-deps && npm run build:server-deps && npm run build --workspace=@getpaseo/server && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && 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",
|
||||
@@ -77,9 +87,9 @@
|
||||
"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: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:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client && npm run typecheck --workspace=@getpaseo/client && npm run build:server && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && 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/protocol --access public && npm publish --dry-run --workspace=@getpaseo/client --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/protocol --access public && npm publish --workspace=@getpaseo/client --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",
|
||||
|
||||
@@ -321,7 +321,7 @@ function ensureRelayBuildArtifact(repoRoot: string): void {
|
||||
}
|
||||
|
||||
console.log("[e2e] Building @getpaseo/relay for daemon startup");
|
||||
execSync("npm run build --workspace=@getpaseo/relay", {
|
||||
execSync("npm run build:relay", {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { loadDaemonClientConstructor } from "./daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
|
||||
@@ -91,21 +90,11 @@ interface DaemonClientConfig {
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: DaemonClientConfig) => DaemonClientInstance
|
||||
> {
|
||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: DaemonClientConfig) => DaemonClientInstance;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const DaemonClient = await loadDaemonClientConstructor<
|
||||
DaemonClientConfig,
|
||||
DaemonClientInstance
|
||||
>();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
import { loadDaemonClientConstructor } from "./daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
|
||||
import {
|
||||
@@ -83,21 +82,11 @@ interface ArchiveTabDaemonClientConfig {
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const DaemonClient = await loadDaemonClientConstructor<
|
||||
ArchiveTabDaemonClientConfig,
|
||||
ArchiveTabDaemonClient
|
||||
>();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
|
||||
15
packages/app/e2e/helpers/daemon-client-loader.ts
Normal file
15
packages/app/e2e/helpers/daemon-client-loader.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export async function loadDaemonClientConstructor<ClientConfig, ClientInstance>(): Promise<
|
||||
new (config: ClientConfig) => ClientInstance
|
||||
> {
|
||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/client/dist/daemon-client.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: ClientConfig) => ClientInstance;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { DaemonClient as ServerDaemonClient } from "@server/client/daemon-client";
|
||||
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||
import { loadDaemonClientConstructor } from "./daemon-client-loader";
|
||||
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
|
||||
type NewWorkspaceDaemonClient = Pick<
|
||||
ServerDaemonClient,
|
||||
InternalDaemonClient,
|
||||
| "archivePaseoWorktree"
|
||||
| "archiveWorkspace"
|
||||
| "close"
|
||||
@@ -48,19 +47,6 @@ function getDaemonWsUrl(): string {
|
||||
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
function requireWorkspace(payload: OpenProjectPayload) {
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
@@ -83,7 +69,10 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
|
||||
}
|
||||
|
||||
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const DaemonClient = await loadDaemonClientConstructor<
|
||||
NewWorkspaceDaemonClientConfig,
|
||||
NewWorkspaceDaemonClient
|
||||
>();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
|
||||
@@ -27,6 +27,7 @@ interface ProviderLaunchConfig {
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
modeId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const SEND_TIMEOUT_MS = 240_000;
|
||||
@@ -52,7 +53,12 @@ function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
|
||||
modeId: "full-access",
|
||||
};
|
||||
case "opencode":
|
||||
return { provider, model: "opencode/big-pickle", modeId: "full-access" };
|
||||
return {
|
||||
provider,
|
||||
model: "opencode/big-pickle",
|
||||
modeId: "build",
|
||||
featureValues: { auto_accept: true },
|
||||
};
|
||||
case "pi":
|
||||
return {
|
||||
provider,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { loadDaemonClientConstructor } from "./daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
|
||||
@@ -81,21 +81,11 @@ interface TerminalPerfDaemonClientConfig {
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const DaemonClient = await loadDaemonClientConstructor<
|
||||
TerminalPerfDaemonClientConfig,
|
||||
TerminalPerfDaemonClient
|
||||
>();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
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 { loadDaemonClientConstructor } from "./daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { switchWorkspaceViaSidebar } from "./workspace-ui";
|
||||
import type { SessionOutboundMessage } from "@server/shared/messages";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
|
||||
interface WorkspaceSetupDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
@@ -70,31 +69,16 @@ function getDaemonWsUrl(): string {
|
||||
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: {
|
||||
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor<
|
||||
{
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}) => WorkspaceSetupDaemonClient;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
},
|
||||
WorkspaceSetupDaemonClient
|
||||
>();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
|
||||
@@ -15,6 +15,7 @@ OUT_DIR="/tmp/paseo-workspace-create-android-focus-$(date +%s)"
|
||||
VIDEO_DIR="/tmp/paseo-maestro-videos"
|
||||
DEVICE_VIDEO="/sdcard/paseo-maestro-workspace-create-focused.mp4"
|
||||
LOCAL_VIDEO="$VIDEO_DIR/paseo-maestro-workspace-create-focused.mp4"
|
||||
CLIENT_EXPORTS="$REPO_ROOT/packages/client/dist/daemon-client.js"
|
||||
|
||||
export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
|
||||
export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}"
|
||||
@@ -55,6 +56,12 @@ require_command perl
|
||||
|
||||
mkdir -p "$OUT_DIR" "$VIDEO_DIR"
|
||||
|
||||
if [ ! -f "$CLIENT_EXPORTS" ]; then
|
||||
echo "Missing client build artifact: $CLIENT_EXPORTS" >&2
|
||||
echo "Run: npm run build:client" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${PASEO_MAESTRO_PROJECT_PATH:-}" ]; then
|
||||
PROJECT_PARENT="$(mktemp -d /tmp/paseo-maestro-project-XXXXXX)"
|
||||
PROJECT_BASENAME="aaa-workspace-create-android-$(basename "$PROJECT_PARENT")"
|
||||
@@ -96,7 +103,7 @@ if (!repoRoot || !projectPath || !daemonUrl) {
|
||||
throw new Error("Missing required environment for daemon project setup.");
|
||||
}
|
||||
|
||||
const moduleUrl = pathToFileURL(`${repoRoot}/packages/server/dist/server/server/exports.js`).href;
|
||||
const moduleUrl = pathToFileURL(`${repoRoot}/packages/client/dist/daemon-client.js`).href;
|
||||
const { DaemonClient } = await import(moduleUrl);
|
||||
const client = new DaemonClient({
|
||||
url: daemonUrl,
|
||||
|
||||
@@ -24,7 +24,7 @@ REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
||||
FLOW_TEMPLATE="$REPO_ROOT/packages/app/maestro/workspace-create-android-crash.yaml"
|
||||
FLOW_TEMPLATE_DIR="$REPO_ROOT/packages/app/maestro"
|
||||
OUT_DIR="/tmp/paseo-workspace-create-android-$(date +%s)"
|
||||
SERVER_EXPORTS="$REPO_ROOT/packages/server/dist/server/server/exports.js"
|
||||
CLIENT_EXPORTS="$REPO_ROOT/packages/client/dist/daemon-client.js"
|
||||
|
||||
export PASEO_MAESTRO_APP_ID="${PASEO_MAESTRO_APP_ID:-sh.paseo.debug}"
|
||||
export PASEO_MAESTRO_DIRECT_ENDPOINT="${PASEO_MAESTRO_DIRECT_ENDPOINT:-127.0.0.1:6767}"
|
||||
@@ -62,9 +62,9 @@ render_flow_tree() {
|
||||
done
|
||||
}
|
||||
|
||||
if [ ! -f "$SERVER_EXPORTS" ]; then
|
||||
echo "Missing server build artifact: $SERVER_EXPORTS" >&2
|
||||
echo "Run: npm run build --workspace=@getpaseo/server" >&2
|
||||
if [ ! -f "$CLIENT_EXPORTS" ]; then
|
||||
echo "Missing client build artifact: $CLIENT_EXPORTS" >&2
|
||||
echo "Run: npm run build:client" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -117,7 +117,7 @@ if (!repoRoot || !projectPath || !daemonUrl) {
|
||||
throw new Error("Missing required environment for daemon project setup.");
|
||||
}
|
||||
|
||||
const moduleUrl = pathToFileURL(`${repoRoot}/packages/server/dist/server/server/exports.js`).href;
|
||||
const moduleUrl = pathToFileURL(`${repoRoot}/packages/client/dist/daemon-client.js`).href;
|
||||
const { DaemonClient } = await import(moduleUrl);
|
||||
const client = new DaemonClient({
|
||||
url: daemonUrl,
|
||||
|
||||
@@ -6,7 +6,6 @@ const path = require("path");
|
||||
const projectRoot = __dirname;
|
||||
const appNodeModulesRoot = path.resolve(projectRoot, "node_modules");
|
||||
const appSrcRoot = path.resolve(projectRoot, "src");
|
||||
const serverSrcRoot = path.resolve(projectRoot, "../server/src");
|
||||
const relaySrcRoot = path.resolve(projectRoot, "../relay/src");
|
||||
const customWebPlatform = (process.env.PASEO_WEB_PLATFORM ?? "")
|
||||
.trim()
|
||||
@@ -68,11 +67,7 @@ function resolveWithCustomWebOverlay(context, moduleName, platform) {
|
||||
|
||||
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
||||
const origin = context.originModulePath;
|
||||
if (
|
||||
origin &&
|
||||
(origin.startsWith(serverSrcRoot) || origin.startsWith(relaySrcRoot)) &&
|
||||
moduleName.endsWith(".js")
|
||||
) {
|
||||
if (origin && origin.startsWith(relaySrcRoot) && moduleName.endsWith(".js")) {
|
||||
const tsModuleName = moduleName.replace(/\.js$/, ".ts");
|
||||
const candidatePath = path.resolve(path.dirname(origin), tsModuleName);
|
||||
if (fs.existsSync(candidatePath)) {
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.83",
|
||||
"version": "0.1.85",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "cross-env APP_VARIANT=development expo start",
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
|
||||
"start": "npm --prefix ../.. run build:client && concurrently --kill-others --names protocol,client,expo --prefix-colors yellow,blue,magenta \"npm --prefix ../.. run watch:protocol\" \"npm --prefix ../.. run watch:client\" \"npm run start:expo\"",
|
||||
"start:expo": "cross-env APP_VARIANT=development 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 && npm run build:terminal-webview",
|
||||
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
|
||||
"android": "npm run android:development",
|
||||
"android:development": "cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
|
||||
"android:development": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
|
||||
"android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
|
||||
"android:release": "npm run android:production",
|
||||
"android:clear": "node -e \"require('node:fs').rmSync('android', { recursive: true, force: true })\"",
|
||||
"ios": "expo run:ios",
|
||||
"ios:release": "expo run:ios --configuration Release",
|
||||
"web": "expo start --web",
|
||||
"ios": "npm --prefix ../.. run build:client && expo run:ios",
|
||||
"ios:release": "npm --prefix ../.. run build:client && expo run:ios --configuration Release",
|
||||
"web": "npm --prefix ../.. run build:client && concurrently --kill-others --names protocol,client,expo --prefix-colors yellow,blue,magenta \"npm --prefix ../.. run watch:protocol\" \"npm --prefix ../.. run watch:client\" \"npm run web:expo\"",
|
||||
"web:expo": "expo start --web",
|
||||
"lint": "expo lint",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "vitest run",
|
||||
@@ -25,16 +25,19 @@
|
||||
"test:e2e:real": "playwright test --project=real-provider",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"build": "npm run build:web",
|
||||
"build:web": "npm run build:workspace-deps && expo export --platform web",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main"
|
||||
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/client": "*",
|
||||
"@getpaseo/expo-two-way-audio": "*",
|
||||
"@getpaseo/highlight": "*",
|
||||
"@getpaseo/protocol": "*",
|
||||
"@gorhom/bottom-sheet": "^5.2.14",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -93,6 +96,7 @@
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "^15.14.0",
|
||||
"react-native-uitextview": "^2.2.0",
|
||||
"react-native-unistyles": "^3.2.4",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-webview": "^13.16.0",
|
||||
@@ -104,6 +108,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
|
||||
@@ -123,6 +123,43 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
|
||||
}
|
||||
|
||||
describe("layoutStream", () => {
|
||||
it.each(["web", "android"] as const)(
|
||||
"keeps split assistant block spacing identical to unsplit history on %s",
|
||||
(platform) => {
|
||||
const firstBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
|
||||
const secondBlock = assistantMessage("turn:block:1", 3, { groupId: "turn", index: 1 });
|
||||
const thirdBlock = assistantMessage("turn:block:2", 4, { groupId: "turn", index: 2 });
|
||||
const splitLayout = layoutFor({
|
||||
platform,
|
||||
agentStatus: "running",
|
||||
tail: [userMessage("u1", 1), firstBlock],
|
||||
head: [secondBlock, thirdBlock],
|
||||
timingIds: [firstBlock.id, secondBlock.id, thirdBlock.id],
|
||||
});
|
||||
const unsplitLayout = layoutFor({
|
||||
platform,
|
||||
agentStatus: "running",
|
||||
tail: [userMessage("u1", 1), firstBlock, secondBlock, thirdBlock],
|
||||
timingIds: [firstBlock.id, secondBlock.id, thirdBlock.id],
|
||||
});
|
||||
|
||||
expect(findLayoutItem(splitLayout, firstBlock.id).belowItem?.id).toBe(secondBlock.id);
|
||||
expect(findLayoutItem(splitLayout, secondBlock.id).aboveItem?.id).toBe(firstBlock.id);
|
||||
expect(findLayoutItem(splitLayout, firstBlock.id).assistantSpacing).toBe(
|
||||
findLayoutItem(unsplitLayout, firstBlock.id).assistantSpacing,
|
||||
);
|
||||
expect(findLayoutItem(splitLayout, secondBlock.id).assistantSpacing).toBe(
|
||||
findLayoutItem(unsplitLayout, secondBlock.id).assistantSpacing,
|
||||
);
|
||||
expect(findLayoutItem(splitLayout, firstBlock.id).gapBelow).toBe(
|
||||
findLayoutItem(unsplitLayout, firstBlock.id).gapBelow,
|
||||
);
|
||||
expect(findLayoutItem(splitLayout, secondBlock.id).gapBelow).toBe(
|
||||
findLayoutItem(unsplitLayout, secondBlock.id).gapBelow,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not duplicate footers when a native assistant turn spans history and live head", () => {
|
||||
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
|
||||
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
|
||||
@@ -195,6 +232,39 @@ describe("layoutStream", () => {
|
||||
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactTop");
|
||||
});
|
||||
|
||||
it.each(["web", "android"] as const)(
|
||||
"keeps split tool sequencing and gapBelow identical to unsplit history on %s",
|
||||
(platform) => {
|
||||
const shell = toolCall("tool-1", 2);
|
||||
const thinking = thought("thought-1", 3);
|
||||
const assistant = assistantMessage("a1", 4);
|
||||
const splitLayout = layoutFor({
|
||||
platform,
|
||||
tail: [userMessage("u1", 1), shell],
|
||||
head: [thinking, assistant],
|
||||
});
|
||||
const unsplitLayout = layoutFor({
|
||||
platform,
|
||||
tail: [userMessage("u1", 1), shell, thinking, assistant],
|
||||
});
|
||||
|
||||
expect(findLayoutItem(splitLayout, shell.id).belowItem?.id).toBe(thinking.id);
|
||||
expect(findLayoutItem(splitLayout, thinking.id).aboveItem?.id).toBe(shell.id);
|
||||
expect(findLayoutItem(splitLayout, shell.id).toolSequence).toBe(
|
||||
findLayoutItem(unsplitLayout, shell.id).toolSequence,
|
||||
);
|
||||
expect(findLayoutItem(splitLayout, thinking.id).toolSequence).toBe(
|
||||
findLayoutItem(unsplitLayout, thinking.id).toolSequence,
|
||||
);
|
||||
expect(findLayoutItem(splitLayout, shell.id).gapBelow).toBe(
|
||||
findLayoutItem(unsplitLayout, shell.id).gapBelow,
|
||||
);
|
||||
expect(findLayoutItem(splitLayout, thinking.id).gapBelow).toBe(
|
||||
findLayoutItem(unsplitLayout, thinking.id).gapBelow,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("computes tool sequence position from strategy-aware neighbors", () => {
|
||||
const shell = toolCall("tool-1", 2);
|
||||
const thinking = thought("thought-1", 3);
|
||||
|
||||
@@ -32,7 +32,6 @@ export interface StreamLayout {
|
||||
history: StreamLayoutItem[];
|
||||
liveHead: StreamLayoutItem[];
|
||||
auxiliaryTurnFooter: TurnFooterHost | null;
|
||||
historyToHeadGap: number;
|
||||
}
|
||||
|
||||
export interface StreamLayoutInput {
|
||||
@@ -243,6 +242,5 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
|
||||
history,
|
||||
liveHead,
|
||||
auxiliaryTurnFooter,
|
||||
historyToHeadGap: getGapBetweenStreamItems(historyBoundaryItem, liveHeadBoundaryItem),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface StreamHistoryBoundary {
|
||||
hasVirtualizedHistory: boolean;
|
||||
hasMountedHistory: boolean;
|
||||
hasLiveHead: boolean;
|
||||
historyToHeadGap: number;
|
||||
}
|
||||
|
||||
export interface StreamRenderAuxiliary {
|
||||
@@ -205,7 +204,6 @@ export function buildAgentStreamRenderModel(
|
||||
hasVirtualizedHistory: splitHistory.segments.historyVirtualized.length > 0,
|
||||
hasMountedHistory: splitHistory.segments.historyMounted.length > 0,
|
||||
hasLiveHead: orderedHead.length > 0,
|
||||
historyToHeadGap: 0,
|
||||
},
|
||||
auxiliary: EMPTY_AUXILIARY,
|
||||
};
|
||||
|
||||
@@ -125,7 +125,6 @@ describe("createWebStreamStrategy", () => {
|
||||
hasVirtualizedHistory: true,
|
||||
hasMountedHistory: false,
|
||||
hasLiveHead: false,
|
||||
historyToHeadGap: 0,
|
||||
},
|
||||
renderers: createRenderers(rowRenderCount),
|
||||
listEmptyComponent: null,
|
||||
@@ -171,7 +170,6 @@ describe("createWebStreamStrategy", () => {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
historyToHeadGap: 0,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
|
||||
@@ -584,9 +584,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
</div>
|
||||
) : null}
|
||||
{mountedHistoryRows}
|
||||
{boundary.hasMountedHistory && boundary.hasLiveHead && boundary.historyToHeadGap > 0 ? (
|
||||
<HistoryToHeadSpacer height={boundary.historyToHeadGap} />
|
||||
) : null}
|
||||
{liveHeadRows}
|
||||
{liveAuxiliary}
|
||||
{shouldRenderEmpty ? listEmptyComponent : null}
|
||||
@@ -634,12 +631,3 @@ export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): St
|
||||
getBottomOffset: (metrics) => Math.max(0, metrics.contentHeight - metrics.viewportHeight),
|
||||
});
|
||||
}
|
||||
|
||||
interface HistoryToHeadSpacerProps {
|
||||
height: number;
|
||||
}
|
||||
|
||||
function HistoryToHeadSpacer({ height }: HistoryToHeadSpacerProps) {
|
||||
const spacerStyle = useMemo(() => ({ height, width: "100%" as const }), [height]);
|
||||
return <div style={spacerStyle} />;
|
||||
}
|
||||
|
||||
@@ -43,13 +43,13 @@ import type { PendingPermission } from "@/types/shared";
|
||||
import type {
|
||||
AgentPermissionAction,
|
||||
AgentPermissionResponse,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { ToolCallDetailsContent } from "@/components/tool-call-details";
|
||||
import { QuestionFormCard } from "@/components/question-form-card";
|
||||
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
|
||||
@@ -613,16 +613,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const renderModel = useMemo<AgentStreamRenderModel>(() => {
|
||||
return {
|
||||
...baseRenderModel,
|
||||
boundary: {
|
||||
...baseRenderModel.boundary,
|
||||
historyToHeadGap: streamLayout.historyToHeadGap,
|
||||
},
|
||||
boundary: baseRenderModel.boundary,
|
||||
auxiliary: {
|
||||
pendingPermissions: pendingPermissionsNode,
|
||||
turnFooter: turnFooterNode,
|
||||
},
|
||||
};
|
||||
}, [baseRenderModel, pendingPermissionsNode, streamLayout.historyToHeadGap, turnFooterNode]);
|
||||
}, [baseRenderModel, pendingPermissionsNode, turnFooterNode]);
|
||||
|
||||
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
|
||||
const listEmptyComponent = useMemo(
|
||||
|
||||
@@ -28,6 +28,7 @@ import { QuittingOverlay } from "@/components/quitting-overlay";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||
import { ProviderSettingsHost } from "@/components/provider-settings-host";
|
||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
|
||||
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
|
||||
@@ -466,6 +467,7 @@ function AppContainer({
|
||||
<WorktreeSetupCalloutSource />
|
||||
<CommandCenter />
|
||||
<ProjectPickerModal />
|
||||
<ProviderSettingsHost />
|
||||
<WorkspaceShortcutTargetsSubscriber
|
||||
enabled={keyboardShortcutsEnabled}
|
||||
serverId={activeServerId}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { act } from "@testing-library/react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HostRuntimeBootstrapState } from "./_layout";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
const { redirectMock, state } = vi.hoisted(() => {
|
||||
const hoistedState = {
|
||||
pathname: "/",
|
||||
bootstrapState: {
|
||||
splashError: null,
|
||||
retry: vi.fn(),
|
||||
hasGivenUpWaitingForHost: false,
|
||||
storeReady: false,
|
||||
} as HostRuntimeBootstrapState,
|
||||
anyOnlineHostServerId: null as string | null,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
workspaceSelection: null as ActiveWorkspaceSelection | null,
|
||||
};
|
||||
|
||||
return {
|
||||
redirectMock: vi.fn(),
|
||||
state: hoistedState,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
Redirect: ({ href }: { href: string }) => {
|
||||
redirectMock(href);
|
||||
return React.createElement("div", { "data-testid": "redirect", "data-href": href });
|
||||
},
|
||||
usePathname: () => state.pathname,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/_layout", () => ({
|
||||
useHostRuntimeBootstrapState: () => state.bootstrapState,
|
||||
useEarliestOnlineHostServerId: () => state.anyOnlineHostServerId,
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/daemon/desktop-daemon", () => ({
|
||||
shouldUseDesktopDaemon: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("@/screens/startup-splash-screen", () => ({
|
||||
StartupSplashScreen: () => React.createElement("div", { "data-testid": "startup-splash" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/navigation-active-workspace-store", () => ({
|
||||
useIsLastWorkspaceSelectionHydrated: () => state.isWorkspaceSelectionLoaded,
|
||||
useLastWorkspaceSelection: () => state.workspaceSelection,
|
||||
}));
|
||||
|
||||
describe("Index route startup navigation", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
state.pathname = "/";
|
||||
state.bootstrapState = {
|
||||
splashError: null,
|
||||
retry: vi.fn(),
|
||||
hasGivenUpWaitingForHost: false,
|
||||
storeReady: false,
|
||||
};
|
||||
state.anyOnlineHostServerId = null;
|
||||
state.isWorkspaceSelectionLoaded = true;
|
||||
state.workspaceSelection = null;
|
||||
redirectMock.mockReset();
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
async function renderIndex() {
|
||||
const { default: Index } = await import("./index");
|
||||
await act(async () => {
|
||||
root.render(<Index />);
|
||||
});
|
||||
}
|
||||
|
||||
it("shows the startup splash while no host is online and the welcome timer has not fired", async () => {
|
||||
await renderIndex();
|
||||
|
||||
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the startup splash while the persisted workspace selection has not loaded", async () => {
|
||||
state.anyOnlineHostServerId = "server-1";
|
||||
state.isWorkspaceSelectionLoaded = false;
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the persisted workspace when the online host matches its server id", async () => {
|
||||
state.anyOnlineHostServerId = "server-1";
|
||||
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
|
||||
expect(container.querySelector("[data-testid='redirect']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("restores the persisted workspace even when the first online host is different", async () => {
|
||||
state.anyOnlineHostServerId = "server-2";
|
||||
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(redirectMock).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
|
||||
});
|
||||
|
||||
it("navigates to the host root when no persisted workspace exists", async () => {
|
||||
state.anyOnlineHostServerId = "server-2";
|
||||
state.workspaceSelection = null;
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
|
||||
});
|
||||
|
||||
it("falls back to welcome when the give-up timer fires with no host online", async () => {
|
||||
state.bootstrapState = {
|
||||
...state.bootstrapState,
|
||||
hasGivenUpWaitingForHost: true,
|
||||
};
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(redirectMock).toHaveBeenCalledWith("/welcome");
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
|
||||
import { 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 { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
|
||||
54
packages/app/src/attachments/attachment-file-system.ts
Normal file
54
packages/app/src/attachments/attachment-file-system.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { File } from "expo-file-system";
|
||||
import * as FileSystem from "expo-file-system/legacy";
|
||||
|
||||
export type AttachmentFileInfo =
|
||||
| { exists: true; isDirectory: boolean; size: number | null }
|
||||
| { exists: false };
|
||||
|
||||
export interface AttachmentFileSystem {
|
||||
readonly cacheDirectory: string | null;
|
||||
getInfo(uri: string): Promise<AttachmentFileInfo>;
|
||||
makeDirectory(uri: string, options: { intermediates: boolean }): Promise<void>;
|
||||
writeBytes(uri: string, bytes: Uint8Array): Promise<void>;
|
||||
copy(input: { from: string; to: string }): Promise<void>;
|
||||
readAsBase64(uri: string): Promise<string>;
|
||||
delete(uri: string, options: { idempotent: boolean }): Promise<void>;
|
||||
listDirectory(uri: string): Promise<string[]>;
|
||||
}
|
||||
|
||||
export function createExpoAttachmentFileSystem(): AttachmentFileSystem {
|
||||
return {
|
||||
cacheDirectory: FileSystem.cacheDirectory,
|
||||
async getInfo(uri) {
|
||||
const info = await FileSystem.getInfoAsync(uri);
|
||||
if (!info.exists) {
|
||||
return { exists: false };
|
||||
}
|
||||
const size =
|
||||
typeof (info as { size?: number }).size === "number"
|
||||
? (info as { size: number }).size
|
||||
: null;
|
||||
return { exists: true, isDirectory: info.isDirectory ?? false, size };
|
||||
},
|
||||
async makeDirectory(uri, options) {
|
||||
await FileSystem.makeDirectoryAsync(uri, options);
|
||||
},
|
||||
async writeBytes(uri, bytes) {
|
||||
new File(uri).write(bytes);
|
||||
},
|
||||
async copy(input) {
|
||||
await FileSystem.copyAsync(input);
|
||||
},
|
||||
async readAsBase64(uri) {
|
||||
return await FileSystem.readAsStringAsync(uri, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
},
|
||||
async delete(uri, options) {
|
||||
await FileSystem.deleteAsync(uri, options);
|
||||
},
|
||||
async listDirectory(uri) {
|
||||
return await FileSystem.readDirectoryAsync(uri);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,64 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createLocalFileAttachmentStore } from "./local-file-attachment-store";
|
||||
|
||||
const fileSystemMock = vi.hoisted(() => ({
|
||||
getInfoAsync: vi.fn(async (uri: string) => {
|
||||
if (uri.endsWith("/preview-assets/")) {
|
||||
return { exists: true, isDirectory: true };
|
||||
}
|
||||
return { exists: true, isDirectory: false, size: 4 };
|
||||
}),
|
||||
makeDirectoryAsync: vi.fn(async () => {}),
|
||||
writeAsStringAsync: vi.fn(async () => {}),
|
||||
copyAsync: vi.fn(async () => {}),
|
||||
readAsStringAsync: vi.fn(async () => "AAECAw=="),
|
||||
deleteAsync: vi.fn(async () => {}),
|
||||
readDirectoryAsync: vi.fn(async () => []),
|
||||
fileWrite: vi.fn((_uri: string, _content: Uint8Array) => {}),
|
||||
}));
|
||||
|
||||
vi.mock("expo-file-system", () => ({
|
||||
File: class {
|
||||
uri: string;
|
||||
|
||||
constructor(uri: string) {
|
||||
this.uri = uri;
|
||||
}
|
||||
|
||||
write(content: Uint8Array) {
|
||||
fileSystemMock.fileWrite(this.uri, content);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("expo-file-system/legacy", () => ({
|
||||
cacheDirectory: "file:///cache/",
|
||||
EncodingType: { Base64: "base64" },
|
||||
getInfoAsync: fileSystemMock.getInfoAsync,
|
||||
makeDirectoryAsync: fileSystemMock.makeDirectoryAsync,
|
||||
writeAsStringAsync: fileSystemMock.writeAsStringAsync,
|
||||
copyAsync: fileSystemMock.copyAsync,
|
||||
readAsStringAsync: fileSystemMock.readAsStringAsync,
|
||||
deleteAsync: fileSystemMock.deleteAsync,
|
||||
readDirectoryAsync: fileSystemMock.readDirectoryAsync,
|
||||
}));
|
||||
import { createTestAttachmentFileSystem } from "./test-attachment-file-system";
|
||||
|
||||
describe("local file attachment store", () => {
|
||||
beforeEach(() => {
|
||||
fileSystemMock.getInfoAsync.mockClear();
|
||||
fileSystemMock.makeDirectoryAsync.mockClear();
|
||||
fileSystemMock.writeAsStringAsync.mockClear();
|
||||
fileSystemMock.copyAsync.mockClear();
|
||||
fileSystemMock.readAsStringAsync.mockClear();
|
||||
fileSystemMock.deleteAsync.mockClear();
|
||||
fileSystemMock.readDirectoryAsync.mockClear();
|
||||
fileSystemMock.fileWrite.mockClear();
|
||||
});
|
||||
|
||||
it("writes raw byte sources directly to the managed file path", async () => {
|
||||
const fileSystem = createTestAttachmentFileSystem();
|
||||
const store = createLocalFileAttachmentStore({
|
||||
storageType: "native-file",
|
||||
baseDirectoryName: "preview-assets",
|
||||
fileSystem,
|
||||
resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`,
|
||||
});
|
||||
|
||||
@@ -69,11 +19,6 @@ describe("local file attachment store", () => {
|
||||
source: { kind: "bytes", bytes: new Uint8Array([0, 1, 2, 3]) },
|
||||
});
|
||||
|
||||
expect(fileSystemMock.fileWrite).toHaveBeenCalledWith(
|
||||
"file:///cache/preview-assets/preview_8_test.png",
|
||||
new Uint8Array([0, 1, 2, 3]),
|
||||
);
|
||||
expect(fileSystemMock.writeAsStringAsync).not.toHaveBeenCalled();
|
||||
expect(attachment).toMatchObject({
|
||||
id: "preview_8_test",
|
||||
mimeType: "image/png",
|
||||
@@ -82,5 +27,9 @@ describe("local file attachment store", () => {
|
||||
fileName: "result.png",
|
||||
byteSize: 4,
|
||||
});
|
||||
expect(fileSystem.files.get("file:///cache/preview-assets/preview_8_test.png")).toEqual(
|
||||
new Uint8Array([0, 1, 2, 3]),
|
||||
);
|
||||
expect(fileSystem.directories.has("file:///cache/preview-assets")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { File } from "expo-file-system";
|
||||
import * as FileSystem from "expo-file-system/legacy";
|
||||
import type { AttachmentFileSystem } from "@/attachments/attachment-file-system";
|
||||
import {
|
||||
type AttachmentStore,
|
||||
type AttachmentStorageType,
|
||||
@@ -37,12 +36,12 @@ function extensionForAttachment(params: { fileName?: string | null; mimeType: st
|
||||
return IMAGE_EXTENSION_BY_MIME_TYPE[params.mimeType] ?? ".img";
|
||||
}
|
||||
|
||||
async function ensureDirectory(uri: string): Promise<void> {
|
||||
const info = await FileSystem.getInfoAsync(uri);
|
||||
async function ensureDirectory(fileSystem: AttachmentFileSystem, uri: string): Promise<void> {
|
||||
const info = await fileSystem.getInfo(uri);
|
||||
if (info.exists && info.isDirectory) {
|
||||
return;
|
||||
}
|
||||
await FileSystem.makeDirectoryAsync(uri, { intermediates: true });
|
||||
await fileSystem.makeDirectory(uri, { intermediates: true });
|
||||
}
|
||||
|
||||
async function dataUrlToBytes(dataUrl: string): Promise<Uint8Array> {
|
||||
@@ -55,6 +54,7 @@ async function blobToBytes(blob: Blob): Promise<Uint8Array> {
|
||||
}
|
||||
|
||||
async function writeFromSource(input: {
|
||||
fileSystem: AttachmentFileSystem;
|
||||
source: SaveAttachmentInput["source"];
|
||||
targetUri: string;
|
||||
mimeType: string;
|
||||
@@ -64,7 +64,7 @@ async function writeFromSource(input: {
|
||||
if (from === input.targetUri) {
|
||||
return;
|
||||
}
|
||||
await FileSystem.copyAsync({ from, to: input.targetUri });
|
||||
await input.fileSystem.copy({ from, to: input.targetUri });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ async function writeFromSource(input: {
|
||||
bytes = input.source.bytes;
|
||||
}
|
||||
|
||||
new File(input.targetUri).write(bytes);
|
||||
await input.fileSystem.writeBytes(input.targetUri, bytes);
|
||||
}
|
||||
|
||||
function attachmentUri(metadata: AttachmentMetadata): string {
|
||||
@@ -87,11 +87,13 @@ function attachmentUri(metadata: AttachmentMetadata): string {
|
||||
export function createLocalFileAttachmentStore(params: {
|
||||
storageType: Extract<AttachmentStorageType, "desktop-file" | "native-file">;
|
||||
baseDirectoryName: string;
|
||||
fileSystem: AttachmentFileSystem;
|
||||
resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise<string>;
|
||||
releasePreviewUrl?: (input: { attachment: AttachmentMetadata; url: string }) => Promise<void>;
|
||||
}): AttachmentStore {
|
||||
const baseDirectory = FileSystem.cacheDirectory
|
||||
? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/`
|
||||
const { fileSystem } = params;
|
||||
const baseDirectory = fileSystem.cacheDirectory
|
||||
? `${fileSystem.cacheDirectory}${params.baseDirectoryName}/`
|
||||
: null;
|
||||
|
||||
async function resolveTarget(input: SaveAttachmentInput): Promise<{
|
||||
@@ -103,10 +105,10 @@ export function createLocalFileAttachmentStore(params: {
|
||||
storageKey: string;
|
||||
}> {
|
||||
if (!baseDirectory) {
|
||||
throw new Error("expo-file-system cacheDirectory is unavailable.");
|
||||
throw new Error("Attachment file-system cacheDirectory is unavailable.");
|
||||
}
|
||||
|
||||
await ensureDirectory(baseDirectory);
|
||||
await ensureDirectory(fileSystem, baseDirectory);
|
||||
|
||||
const id = input.id ?? generateAttachmentId();
|
||||
let mimeTypeFromSource: string | undefined;
|
||||
@@ -140,16 +142,14 @@ export function createLocalFileAttachmentStore(params: {
|
||||
async save(input): Promise<AttachmentMetadata> {
|
||||
const target = await resolveTarget(input);
|
||||
await writeFromSource({
|
||||
fileSystem,
|
||||
source: input.source,
|
||||
targetUri: target.targetUri,
|
||||
mimeType: target.mimeType,
|
||||
});
|
||||
|
||||
const info = await FileSystem.getInfoAsync(target.targetUri);
|
||||
const byteSize =
|
||||
info.exists && typeof (info as { size?: number }).size === "number"
|
||||
? (info as { size: number }).size
|
||||
: null;
|
||||
const info = await fileSystem.getInfo(target.targetUri);
|
||||
const byteSize = info.exists ? info.size : null;
|
||||
return {
|
||||
id: target.id,
|
||||
mimeType: target.mimeType,
|
||||
@@ -162,10 +162,7 @@ export function createLocalFileAttachmentStore(params: {
|
||||
},
|
||||
|
||||
async encodeBase64({ attachment }): Promise<string> {
|
||||
const uri = attachmentUri(attachment);
|
||||
return await FileSystem.readAsStringAsync(uri, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
return await fileSystem.readAsBase64(attachmentUri(attachment));
|
||||
},
|
||||
|
||||
async resolvePreviewUrl({ attachment }): Promise<string> {
|
||||
@@ -184,22 +181,22 @@ export function createLocalFileAttachmentStore(params: {
|
||||
: {}),
|
||||
|
||||
async delete({ attachment }): Promise<void> {
|
||||
await FileSystem.deleteAsync(attachmentUri(attachment), { idempotent: true });
|
||||
await fileSystem.delete(attachmentUri(attachment), { idempotent: true });
|
||||
},
|
||||
|
||||
async garbageCollect({ referencedIds }): Promise<void> {
|
||||
if (!baseDirectory) {
|
||||
return;
|
||||
}
|
||||
await ensureDirectory(baseDirectory);
|
||||
const entries = await FileSystem.readDirectoryAsync(baseDirectory);
|
||||
await ensureDirectory(fileSystem, baseDirectory);
|
||||
const entries = await fileSystem.listDirectory(baseDirectory);
|
||||
await Promise.all(
|
||||
entries.map(async (entryName) => {
|
||||
const id = entryName.split(".", 1)[0] ?? "";
|
||||
if (!id || referencedIds.has(id)) {
|
||||
return;
|
||||
}
|
||||
await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, {
|
||||
await fileSystem.delete(`${baseDirectory}${entryName}`, {
|
||||
idempotent: true,
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createExpoAttachmentFileSystem } from "@/attachments/attachment-file-system";
|
||||
import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
@@ -5,6 +6,7 @@ export function createNativeFileAttachmentStore() {
|
||||
return createLocalFileAttachmentStore({
|
||||
storageType: "native-file",
|
||||
baseDirectoryName: "paseo-native-attachments",
|
||||
fileSystem: createExpoAttachmentFileSystem(),
|
||||
resolvePreviewUrl: async (attachment) => {
|
||||
if (attachment.storageKey.startsWith("file://")) {
|
||||
return attachment.storageKey;
|
||||
|
||||
@@ -9,7 +9,9 @@ async function createAttachmentStore(): Promise<AttachmentStore> {
|
||||
if (isElectronRuntime()) {
|
||||
const { createDesktopAttachmentStore } =
|
||||
await import("../desktop/attachments/desktop-attachment-store");
|
||||
return createDesktopAttachmentStore();
|
||||
const { createDesktopAttachmentBridge } =
|
||||
await import("../desktop/attachments/desktop-attachment-bridge");
|
||||
return createDesktopAttachmentStore(createDesktopAttachmentBridge());
|
||||
}
|
||||
|
||||
const { createIndexedDbAttachmentStore } = await import("./web/indexeddb-attachment-store");
|
||||
|
||||
95
packages/app/src/attachments/test-attachment-file-system.ts
Normal file
95
packages/app/src/attachments/test-attachment-file-system.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { AttachmentFileInfo, AttachmentFileSystem } from "./attachment-file-system";
|
||||
|
||||
export interface TestAttachmentFileSystem extends AttachmentFileSystem {
|
||||
readonly files: ReadonlyMap<string, Uint8Array>;
|
||||
readonly directories: ReadonlySet<string>;
|
||||
setFile(uri: string, bytes: Uint8Array): void;
|
||||
setDirectory(uri: string): void;
|
||||
}
|
||||
|
||||
export function createTestAttachmentFileSystem(options?: {
|
||||
cacheDirectory?: string | null;
|
||||
}): TestAttachmentFileSystem {
|
||||
const files = new Map<string, Uint8Array>();
|
||||
const directories = new Set<string>();
|
||||
const cacheDirectory =
|
||||
options && Object.hasOwn(options, "cacheDirectory")
|
||||
? (options.cacheDirectory ?? null)
|
||||
: "file:///cache/";
|
||||
|
||||
function describe(uri: string): AttachmentFileInfo {
|
||||
if (directories.has(uri) || directories.has(stripTrailingSlash(uri))) {
|
||||
return { exists: true, isDirectory: true, size: null };
|
||||
}
|
||||
const bytes = files.get(uri);
|
||||
if (bytes) {
|
||||
return { exists: true, isDirectory: false, size: bytes.byteLength };
|
||||
}
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
return {
|
||||
files,
|
||||
directories,
|
||||
cacheDirectory,
|
||||
setFile(uri, bytes) {
|
||||
files.set(uri, bytes);
|
||||
},
|
||||
setDirectory(uri) {
|
||||
directories.add(stripTrailingSlash(uri));
|
||||
},
|
||||
async getInfo(uri) {
|
||||
return describe(uri);
|
||||
},
|
||||
async makeDirectory(uri) {
|
||||
directories.add(stripTrailingSlash(uri));
|
||||
},
|
||||
async writeBytes(uri, bytes) {
|
||||
files.set(uri, bytes);
|
||||
},
|
||||
async copy({ from, to }) {
|
||||
const bytes = files.get(from);
|
||||
if (!bytes) {
|
||||
throw new Error(`copy: source does not exist: ${from}`);
|
||||
}
|
||||
files.set(to, bytes);
|
||||
},
|
||||
async readAsBase64(uri) {
|
||||
const bytes = files.get(uri);
|
||||
if (!bytes) {
|
||||
throw new Error(`readAsBase64: file does not exist: ${uri}`);
|
||||
}
|
||||
return toBase64(bytes);
|
||||
},
|
||||
async delete(uri, deleteOptions) {
|
||||
if (!files.delete(uri) && !deleteOptions.idempotent) {
|
||||
throw new Error(`delete: file does not exist: ${uri}`);
|
||||
}
|
||||
},
|
||||
async listDirectory(uri) {
|
||||
const prefix = uri.endsWith("/") ? uri : `${uri}/`;
|
||||
const entries: string[] = [];
|
||||
for (const path of files.keys()) {
|
||||
if (path.startsWith(prefix)) {
|
||||
entries.push(path.slice(prefix.length));
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stripTrailingSlash(uri: string): string {
|
||||
return uri.endsWith("/") ? uri.slice(0, -1) : uri;
|
||||
}
|
||||
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
if (typeof btoa === "function") {
|
||||
return btoa(binary);
|
||||
}
|
||||
return Buffer.from(binary, "binary").toString("base64");
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
|
||||
import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages";
|
||||
|
||||
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
UserComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
|
||||
export function isWorkspaceAttachment(
|
||||
attachment: ComposerAttachment | undefined,
|
||||
|
||||
@@ -28,9 +28,7 @@ import { isNative, isWeb } from "@/constants/platform";
|
||||
export const SHEET_HORIZONTAL_PADDING_SCALE = 6;
|
||||
|
||||
export interface SheetHeaderSearch {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
initialValue?: string;
|
||||
resetKey?: string | number;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
@@ -167,6 +165,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
inlineTitle: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
@@ -202,6 +201,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
footer: {
|
||||
paddingHorizontal: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.surface2,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
adaptiveInputOutline: {
|
||||
outlineColor: theme.colors.accent,
|
||||
},
|
||||
@@ -352,9 +361,7 @@ export function SheetHeaderView({
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={SEARCH_INPUT_STYLE}
|
||||
placeholder={search.placeholder ?? "Search"}
|
||||
initialValue={search.initialValue}
|
||||
resetKey={search.resetKey}
|
||||
value={search.value}
|
||||
onChangeText={handleSearchChange}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -371,7 +378,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
|
||||
const { theme } = useUnistyles();
|
||||
const back = header.back;
|
||||
const handleBackPress = back?.onPress;
|
||||
const hasInlineRow = Boolean(handleBackPress || header.leading);
|
||||
const hasInlineRow = Boolean(handleBackPress || header.leading || header.actions);
|
||||
if (!hasInlineRow && !header.search) return null;
|
||||
return (
|
||||
<View>
|
||||
@@ -398,6 +405,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
|
||||
<Text style={styles.inlineTitle} numberOfLines={1}>
|
||||
{header.title}
|
||||
</Text>
|
||||
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
|
||||
</View>
|
||||
) : null}
|
||||
{header.search ? (
|
||||
@@ -407,9 +415,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={SEARCH_INPUT_STYLE}
|
||||
placeholder={header.search.placeholder ?? "Search"}
|
||||
initialValue={header.search.initialValue}
|
||||
resetKey={header.search.resetKey}
|
||||
value={header.search.value}
|
||||
onChangeText={header.search.onChange}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
@@ -427,6 +433,8 @@ export interface AdaptiveModalSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
/** Sticky footer rendered below the scrollable content. */
|
||||
footer?: ReactNode;
|
||||
snapPoints?: string[];
|
||||
testID?: string;
|
||||
/** Override the max width of the desktop card. */
|
||||
@@ -441,6 +449,7 @@ export function AdaptiveModalSheet({
|
||||
visible,
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
snapPoints,
|
||||
testID,
|
||||
desktopMaxWidth,
|
||||
@@ -506,6 +515,7 @@ export function AdaptiveModalSheet({
|
||||
) : (
|
||||
<View style={styles.bottomSheetStaticContent}>{children}</View>
|
||||
)}
|
||||
{footer ? <View style={styles.footer}>{footer}</View> : null}
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
@@ -518,13 +528,13 @@ export function AdaptiveModalSheet({
|
||||
style={styles.desktopScroll}
|
||||
contentContainerStyle={styles.desktopContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={styles.desktopStaticContent}>{children}</View>
|
||||
)}
|
||||
{footer ? <View style={styles.footer}>{footer}</View> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
AGENT_LIFECYCLE_STATUSES,
|
||||
type AgentLifecycleStatus,
|
||||
} from "@server/shared/agent-lifecycle";
|
||||
} from "@getpaseo/protocol/agent-lifecycle";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
|
||||
|
||||
@@ -11,9 +11,19 @@ import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Search,
|
||||
Settings,
|
||||
Star,
|
||||
} from "lucide-react-native";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
const IS_WEB = platformIsWeb;
|
||||
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
@@ -48,7 +58,6 @@ import {
|
||||
buildSelectedTriggerLabel,
|
||||
filterAndRankModelRows,
|
||||
getAllProviderModelRows,
|
||||
getProviderDefaultLabel,
|
||||
getProviderModelRows,
|
||||
resolveSelectedModelLabel,
|
||||
type ProviderSelectionModelRow,
|
||||
@@ -79,6 +88,7 @@ interface CombinedModelSelectorProps {
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
disabled?: boolean;
|
||||
serverId?: string | null;
|
||||
}
|
||||
|
||||
interface SelectorContentProps {
|
||||
@@ -91,6 +101,7 @@ interface SelectorContentProps {
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
serverId: string | null;
|
||||
}
|
||||
|
||||
function normalizeSearchQuery(value: string): string {
|
||||
@@ -268,42 +279,60 @@ function FavoritesSection({
|
||||
}
|
||||
|
||||
interface GroupProviderButtonProps {
|
||||
providerId: string;
|
||||
providerLabel: string;
|
||||
rowCount: number;
|
||||
defaultLabel: string | null;
|
||||
provider: ProviderSelectorProvider;
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
onSelectDefault: (providerId: string) => void;
|
||||
}
|
||||
|
||||
function GroupProviderButton({
|
||||
providerId,
|
||||
providerLabel,
|
||||
rowCount,
|
||||
defaultLabel,
|
||||
onDrillDown,
|
||||
onSelectDefault,
|
||||
}: GroupProviderButtonProps) {
|
||||
function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [
|
||||
styles.rowIconButton,
|
||||
Boolean(hovered) && styles.rowIconButtonHovered,
|
||||
pressed && styles.rowIconButtonPressed,
|
||||
];
|
||||
}
|
||||
|
||||
function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const ProvIcon = getProviderIcon(providerId);
|
||||
const ProvIcon = getProviderIcon(provider.id);
|
||||
const selection = provider.modelSelection;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (defaultLabel) {
|
||||
onSelectDefault(providerId);
|
||||
return;
|
||||
}
|
||||
onDrillDown(providerId, providerLabel);
|
||||
}, [defaultLabel, onDrillDown, onSelectDefault, providerId, providerLabel]);
|
||||
onDrillDown(provider.id, provider.label);
|
||||
}, [onDrillDown, provider.id, provider.label]);
|
||||
|
||||
let stateNode: React.ReactNode;
|
||||
if (selection.kind === "models") {
|
||||
const count = selection.rows.length;
|
||||
stateNode = (
|
||||
<Text style={styles.drillDownCount}>{`${count} ${count === 1 ? "model" : "models"}`}</Text>
|
||||
);
|
||||
} else if (selection.kind === "loading") {
|
||||
stateNode = (
|
||||
<View style={styles.rowStateInline}>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.foregroundMuted}
|
||||
style={styles.rowSpinner}
|
||||
/>
|
||||
<Text style={styles.drillDownCount}>Loading</Text>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
stateNode = (
|
||||
<View style={styles.rowStateInline}>
|
||||
<AlertTriangle size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.drillDownCount}>Error</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
||||
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.drillDownText}>{providerLabel}</Text>
|
||||
<Text style={styles.drillDownText}>{provider.label}</Text>
|
||||
<View style={styles.drillDownTrailing}>
|
||||
<Text style={styles.drillDownCount}>
|
||||
{defaultLabel ?? `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
|
||||
</Text>
|
||||
{defaultLabel ? null : (
|
||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
{stateNode}
|
||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -312,64 +341,22 @@ function GroupProviderButton({
|
||||
function GroupedProviderRows({
|
||||
providers,
|
||||
onDrillDown,
|
||||
onSelectDefault,
|
||||
}: {
|
||||
providers: ProviderSelectorProvider[];
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
onSelectDefault: (providerId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<View>
|
||||
{providers.map((provider, index) => {
|
||||
const rows = getProviderModelRows(provider);
|
||||
const defaultLabel = getProviderDefaultLabel(provider);
|
||||
return (
|
||||
<View key={provider.id}>
|
||||
{index > 0 ? <View style={styles.separator} /> : null}
|
||||
<GroupProviderButton
|
||||
providerId={provider.id}
|
||||
providerLabel={provider.label}
|
||||
rowCount={rows.length}
|
||||
defaultLabel={defaultLabel}
|
||||
onDrillDown={onDrillDown}
|
||||
onSelectDefault={onSelectDefault}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{providers.map((provider, index) => (
|
||||
<View key={provider.id}>
|
||||
{index > 0 ? <View style={styles.separator} /> : null}
|
||||
<GroupProviderButton provider={provider} onDrillDown={onDrillDown} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultProviderRow({
|
||||
providerId,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
providerId: string;
|
||||
isSelected: boolean;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const ProviderIcon = getProviderIcon(providerId);
|
||||
const handlePress = useCallback(() => {
|
||||
onSelect(providerId, "");
|
||||
}, [onSelect, providerId]);
|
||||
const leadingSlot = useMemo(
|
||||
() => <ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
[ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
|
||||
);
|
||||
|
||||
return (
|
||||
<ComboboxItem
|
||||
label="Default"
|
||||
selected={isSelected}
|
||||
onPress={handlePress}
|
||||
leadingSlot={leadingSlot}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderModelRows({
|
||||
rows,
|
||||
selectedProvider,
|
||||
@@ -430,6 +417,31 @@ function ProviderModelRows({
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderErrorEmptyState({
|
||||
serverId,
|
||||
providerId,
|
||||
message,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
providerId: string;
|
||||
message: string;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { refresh, isRefreshing } = useProvidersSnapshot(serverId);
|
||||
const handleRetry = useCallback(() => {
|
||||
void refresh([providerId]);
|
||||
}, [refresh, providerId]);
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.emptyStateText}>{message}</Text>
|
||||
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRefreshing}>
|
||||
{isRefreshing ? "Retrying…" : "Retry"}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectorContent({
|
||||
view,
|
||||
providers,
|
||||
@@ -440,6 +452,7 @@ function SelectorContent({
|
||||
onSelect,
|
||||
onToggleFavorite,
|
||||
onDrillDown,
|
||||
serverId,
|
||||
}: SelectorContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
||||
@@ -461,12 +474,6 @@ function SelectorContent({
|
||||
() => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)),
|
||||
[favoriteKeys, providers],
|
||||
);
|
||||
const handleSelectDefaultProvider = useCallback(
|
||||
(providerId: string) => {
|
||||
onSelect(providerId, "");
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
const hasResults = favoriteRows.length > 0 || providers.length > 0;
|
||||
const emptyState = (
|
||||
<View style={styles.emptyState}>
|
||||
@@ -479,17 +486,28 @@ function SelectorContent({
|
||||
if (!selectedViewProvider) {
|
||||
return emptyState;
|
||||
}
|
||||
|
||||
if (getProviderDefaultLabel(selectedViewProvider) && !normalizedQuery) {
|
||||
const drillSelection = selectedViewProvider.modelSelection;
|
||||
if (drillSelection.kind === "loading") {
|
||||
return (
|
||||
<DefaultProviderRow
|
||||
<View style={styles.emptyState}>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.foregroundMuted}
|
||||
style={styles.rowSpinner}
|
||||
/>
|
||||
<Text style={styles.emptyStateText}>Loading</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (drillSelection.kind === "error") {
|
||||
return (
|
||||
<ProviderErrorEmptyState
|
||||
serverId={serverId}
|
||||
providerId={view.providerId}
|
||||
isSelected={view.providerId === selectedProvider && !selectedModel}
|
||||
onSelect={onSelect}
|
||||
message={drillSelection.message}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (visibleRows.length === 0) {
|
||||
return emptyState;
|
||||
}
|
||||
@@ -519,11 +537,7 @@ function SelectorContent({
|
||||
/>
|
||||
|
||||
{providers.length > 0 ? (
|
||||
<GroupedProviderRows
|
||||
providers={providers}
|
||||
onDrillDown={onDrillDown}
|
||||
onSelectDefault={handleSelectDefaultProvider}
|
||||
/>
|
||||
<GroupedProviderRows providers={providers} onDrillDown={onDrillDown} />
|
||||
) : null}
|
||||
|
||||
{!hasResults ? emptyState : null}
|
||||
@@ -543,6 +557,7 @@ export function CombinedModelSelector({
|
||||
onOpen,
|
||||
onClose,
|
||||
disabled = false,
|
||||
serverId = null,
|
||||
}: CombinedModelSelectorProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const anchorRef = useRef<View>(null);
|
||||
@@ -648,6 +663,10 @@ export function CombinedModelSelector({
|
||||
handleOpenChange(!isOpen);
|
||||
}, [handleOpenChange, isOpen]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
handleOpenChange(false);
|
||||
}, [handleOpenChange]);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.trigger,
|
||||
@@ -673,21 +692,42 @@ export function CombinedModelSelector({
|
||||
setSearchQuery(value);
|
||||
}, []);
|
||||
|
||||
const openHeaderProviderSettings = useCallback(() => {
|
||||
if (!serverId || view.kind !== "provider") return;
|
||||
useProviderSettingsStore.getState().open({ serverId, provider: view.providerId });
|
||||
handleClose();
|
||||
}, [serverId, view, handleClose]);
|
||||
|
||||
const sheetHeader = useMemo<SheetHeader>(() => {
|
||||
if (view.kind === "all") {
|
||||
return { title: "Select provider" };
|
||||
}
|
||||
const ProviderIconForView = getProviderIcon(view.providerId);
|
||||
const headerActions = (
|
||||
<Pressable
|
||||
onPress={openHeaderProviderSettings}
|
||||
disabled={!serverId}
|
||||
hitSlop={8}
|
||||
style={iconButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open ${view.providerLabel} settings`}
|
||||
testID={`selector-header-settings-${view.providerId}`}
|
||||
>
|
||||
<Settings
|
||||
size={theme.iconSize.sm}
|
||||
color={!serverId ? theme.colors.border : theme.colors.foregroundMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
return {
|
||||
title: view.providerLabel,
|
||||
leading: ProviderIconForView ? (
|
||||
<ProviderIconForView size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
) : undefined,
|
||||
back: singleProviderView ? undefined : { onPress: handleBackToAll },
|
||||
actions: headerActions,
|
||||
search: {
|
||||
value: searchQuery,
|
||||
onChange: handleSearchQueryChange,
|
||||
initialValue: searchQuery,
|
||||
resetKey: `${view.providerId}:${searchResetKey}`,
|
||||
placeholder: "Search models...",
|
||||
autoFocus: platformIsWeb,
|
||||
@@ -697,11 +737,15 @@ export function CombinedModelSelector({
|
||||
}, [
|
||||
view,
|
||||
singleProviderView,
|
||||
serverId,
|
||||
openHeaderProviderSettings,
|
||||
theme.colors.border,
|
||||
theme.colors.foregroundMuted,
|
||||
handleBackToAll,
|
||||
handleSearchQueryChange,
|
||||
searchQuery,
|
||||
searchResetKey,
|
||||
theme.iconSize.md,
|
||||
theme.iconSize.sm,
|
||||
theme.colors.foreground,
|
||||
]);
|
||||
|
||||
@@ -760,6 +804,7 @@ export function CombinedModelSelector({
|
||||
onSelect={handleSelect}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onDrillDown={handleDrillDown}
|
||||
serverId={serverId}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.sheetLoadingState}>
|
||||
@@ -857,6 +902,34 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
rowStateInline: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
rowErrorText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
maxWidth: 140,
|
||||
},
|
||||
rowIconButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
rowSpinner: {
|
||||
transform: [{ scale: 0.7 }],
|
||||
},
|
||||
rowIconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
rowIconButtonPressed: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
emptyState: {
|
||||
paddingVertical: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
|
||||
@@ -189,7 +189,7 @@ const styles = StyleSheet.create((theme) => {
|
||||
},
|
||||
lineText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
...(isWeb
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dragStateInitial, dragStateReducer } from "./drag-reducer";
|
||||
|
||||
describe("dragStateReducer", () => {
|
||||
it("starts tracking the active item with a snapshot of the data", () => {
|
||||
const next = dragStateReducer(dragStateInitial<string>(), {
|
||||
type: "start",
|
||||
id: "alpha",
|
||||
data: ["alpha", "beta"],
|
||||
});
|
||||
|
||||
expect(next).toEqual({ activeId: "alpha", dragItems: ["alpha", "beta"] });
|
||||
});
|
||||
|
||||
it("clears the active item and the snapshot", () => {
|
||||
const next = dragStateReducer(
|
||||
{ activeId: "alpha", dragItems: ["alpha", "beta"] },
|
||||
{ type: "clear" },
|
||||
);
|
||||
|
||||
expect(next).toEqual({ activeId: null, dragItems: null });
|
||||
});
|
||||
|
||||
it("replaces an in-flight drag when a new one starts", () => {
|
||||
const next = dragStateReducer(
|
||||
{ activeId: "alpha", dragItems: ["alpha", "beta"] },
|
||||
{ type: "start", id: "beta", data: ["beta", "gamma"] },
|
||||
);
|
||||
|
||||
expect(next).toEqual({ activeId: "beta", dragItems: ["beta", "gamma"] });
|
||||
});
|
||||
});
|
||||
19
packages/app/src/components/drag-reorder/drag-reducer.ts
Normal file
19
packages/app/src/components/drag-reorder/drag-reducer.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface DragState<T> {
|
||||
activeId: string | null;
|
||||
dragItems: T[] | null;
|
||||
}
|
||||
|
||||
export type DragAction<T> = { type: "start"; id: string; data: T[] } | { type: "clear" };
|
||||
|
||||
export function dragStateInitial<T>(): DragState<T> {
|
||||
return { activeId: null, dragItems: null };
|
||||
}
|
||||
|
||||
export function dragStateReducer<T>(state: DragState<T>, action: DragAction<T>): DragState<T> {
|
||||
switch (action.type) {
|
||||
case "start":
|
||||
return { activeId: action.id, dragItems: action.data };
|
||||
case "clear":
|
||||
return { activeId: null, dragItems: null };
|
||||
}
|
||||
}
|
||||
12
packages/app/src/components/drag-reorder/index.ts
Normal file
12
packages/app/src/components/drag-reorder/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { reorderItemsOnDragEnd } from "./reorder-items";
|
||||
export type { DragEndInput } from "./reorder-items";
|
||||
export {
|
||||
getPointerActivationConstraint,
|
||||
type PointerActivationConfig,
|
||||
type PointerActivationConstraint,
|
||||
} from "./pointer-activation";
|
||||
export {
|
||||
useDragReorderState,
|
||||
type DragReorderHandlers,
|
||||
type DragReorderState,
|
||||
} from "./use-drag-reorder-state";
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getPointerActivationConstraint } from "./pointer-activation";
|
||||
|
||||
const config = { defaultDistance: 6, holdDelayMs: 250, holdTolerance: 8 };
|
||||
|
||||
describe("getPointerActivationConstraint", () => {
|
||||
it("uses distance activation for default draggable rows", () => {
|
||||
expect(getPointerActivationConstraint(false, config)).toEqual({ distance: 6 });
|
||||
});
|
||||
|
||||
it("requires a held pointer before activating handle-based drags", () => {
|
||||
expect(getPointerActivationConstraint(true, config)).toEqual({ delay: 250, tolerance: 8 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
export type PointerActivationConstraint =
|
||||
| { distance: number }
|
||||
| { delay: number; tolerance: number };
|
||||
|
||||
export interface PointerActivationConfig {
|
||||
defaultDistance: number;
|
||||
holdDelayMs: number;
|
||||
holdTolerance: number;
|
||||
}
|
||||
|
||||
export function getPointerActivationConstraint(
|
||||
useDragHandle: boolean,
|
||||
config: PointerActivationConfig,
|
||||
): PointerActivationConstraint {
|
||||
if (useDragHandle) {
|
||||
return { delay: config.holdDelayMs, tolerance: config.holdTolerance };
|
||||
}
|
||||
return { distance: config.defaultDistance };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { reorderItemsOnDragEnd } from "./reorder-items";
|
||||
|
||||
const items = ["alpha", "beta", "gamma"];
|
||||
const byValue = (item: string): string => item;
|
||||
|
||||
describe("reorderItemsOnDragEnd", () => {
|
||||
it("moves the active item to the over position", () => {
|
||||
expect(
|
||||
reorderItemsOnDragEnd({
|
||||
items,
|
||||
activeId: "alpha",
|
||||
overId: "gamma",
|
||||
keyExtractor: byValue,
|
||||
}),
|
||||
).toEqual(["beta", "gamma", "alpha"]);
|
||||
});
|
||||
|
||||
it("is a no-op when the drop target is missing", () => {
|
||||
expect(
|
||||
reorderItemsOnDragEnd({
|
||||
items,
|
||||
activeId: "alpha",
|
||||
overId: null,
|
||||
keyExtractor: byValue,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("is a no-op when the active and over items are the same", () => {
|
||||
expect(
|
||||
reorderItemsOnDragEnd({
|
||||
items,
|
||||
activeId: "beta",
|
||||
overId: "beta",
|
||||
keyExtractor: byValue,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("is a no-op when the active id is not in the list", () => {
|
||||
expect(
|
||||
reorderItemsOnDragEnd({
|
||||
items,
|
||||
activeId: "delta",
|
||||
overId: "beta",
|
||||
keyExtractor: byValue,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("is a no-op when the over id is not in the list", () => {
|
||||
expect(
|
||||
reorderItemsOnDragEnd({
|
||||
items,
|
||||
activeId: "alpha",
|
||||
overId: "delta",
|
||||
keyExtractor: byValue,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
24
packages/app/src/components/drag-reorder/reorder-items.ts
Normal file
24
packages/app/src/components/drag-reorder/reorder-items.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
|
||||
export interface DragEndInput<T> {
|
||||
items: T[];
|
||||
activeId: string;
|
||||
overId: string | null | undefined;
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
}
|
||||
|
||||
export function reorderItemsOnDragEnd<T>({
|
||||
items,
|
||||
activeId,
|
||||
overId,
|
||||
keyExtractor,
|
||||
}: DragEndInput<T>): T[] | null {
|
||||
if (!overId || activeId === overId) return null;
|
||||
|
||||
const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === activeId);
|
||||
const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === overId);
|
||||
|
||||
if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return null;
|
||||
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useReducer } from "react";
|
||||
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
|
||||
import { dragStateInitial, dragStateReducer } from "./drag-reducer";
|
||||
import { reorderItemsOnDragEnd } from "./reorder-items";
|
||||
|
||||
export interface DragReorderHandlers {
|
||||
onDragStart: (event: DragStartEvent) => void;
|
||||
onDragCancel: () => void;
|
||||
onDragEnd: (event: DragEndEvent) => void;
|
||||
}
|
||||
|
||||
export interface DragReorderState<T> {
|
||||
activeId: string | null;
|
||||
items: T[];
|
||||
handlers: DragReorderHandlers;
|
||||
}
|
||||
|
||||
export function useDragReorderState<T>({
|
||||
data,
|
||||
keyExtractor,
|
||||
onDragEnd,
|
||||
onDragBegin,
|
||||
disabled = false,
|
||||
}: {
|
||||
data: T[];
|
||||
keyExtractor: (item: T, index: number) => string;
|
||||
onDragEnd?: (items: T[]) => void;
|
||||
onDragBegin?: () => void;
|
||||
disabled?: boolean;
|
||||
}): DragReorderState<T> {
|
||||
const [state, dispatch] = useReducer(dragStateReducer<T>, undefined, dragStateInitial<T>);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
if (disabled) return;
|
||||
dispatch({ type: "start", id: String(event.active.id), data });
|
||||
onDragBegin?.();
|
||||
},
|
||||
[data, disabled, onDragBegin],
|
||||
);
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
dispatch({ type: "clear" });
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
const items = state.dragItems ?? data;
|
||||
dispatch({ type: "clear" });
|
||||
if (disabled) return;
|
||||
|
||||
const reordered = reorderItemsOnDragEnd({
|
||||
items,
|
||||
activeId: String(active.id),
|
||||
overId: over ? String(over.id) : null,
|
||||
keyExtractor,
|
||||
});
|
||||
if (reordered) onDragEnd?.(reordered);
|
||||
},
|
||||
[data, disabled, keyExtractor, onDragEnd, state.dragItems],
|
||||
);
|
||||
|
||||
return {
|
||||
activeId: state.activeId,
|
||||
items: state.dragItems ?? data,
|
||||
handlers: {
|
||||
onDragStart: handleDragStart,
|
||||
onDragCancel: clearDragState,
|
||||
onDragEnd: handleDragEnd,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DraggableList } from "./draggable-list.web";
|
||||
|
||||
interface DndContextProps {
|
||||
onDragStart?: (event: { active: { id: string } }) => void;
|
||||
onDragCancel?: () => void;
|
||||
}
|
||||
|
||||
let latestDndContextProps: DndContextProps | null = null;
|
||||
const dndKitMocks = vi.hoisted(() => ({
|
||||
useSensor: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@dnd-kit/core", () => ({
|
||||
DndContext: ({ children, ...props }: React.PropsWithChildren<DndContextProps>) => {
|
||||
latestDndContextProps = props;
|
||||
return <div>{children}</div>;
|
||||
},
|
||||
closestCenter: vi.fn(),
|
||||
KeyboardSensor: vi.fn(),
|
||||
PointerSensor: vi.fn(),
|
||||
useSensor: dndKitMocks.useSensor,
|
||||
useSensors: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("@dnd-kit/sortable", () => ({
|
||||
SortableContext: ({ children }: React.PropsWithChildren) => children,
|
||||
arrayMove: <T,>(items: T[], from: number, to: number) => {
|
||||
const next = [...items];
|
||||
const [item] = next.splice(from, 1);
|
||||
if (item !== undefined) {
|
||||
next.splice(to, 0, item);
|
||||
}
|
||||
return next;
|
||||
},
|
||||
sortableKeyboardCoordinates: vi.fn(),
|
||||
useSortable: () => ({
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: vi.fn(),
|
||||
setActivatorNodeRef: vi.fn(),
|
||||
transform: null,
|
||||
transition: undefined,
|
||||
isDragging: false,
|
||||
}),
|
||||
verticalListSortingStrategy: {},
|
||||
}));
|
||||
|
||||
vi.mock("./use-web-scrollbar", () => ({
|
||||
useWebScrollViewScrollbar: () => ({
|
||||
onLayout: vi.fn(),
|
||||
onContentSizeChange: vi.fn(),
|
||||
onScroll: vi.fn(),
|
||||
overlay: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
latestDndContextProps = null;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const DATA: string[] = ["alpha", "beta"];
|
||||
|
||||
function keyExtractor(item: string): string {
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderItem({ item, isActive }: { item: string; isActive: boolean }) {
|
||||
return (
|
||||
<div data-active={String(isActive)} data-testid={`item-${item}`}>
|
||||
{item}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderList({ useDragHandle = false }: { useDragHandle?: boolean } = {}): void {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<DraggableList
|
||||
data={DATA}
|
||||
keyExtractor={keyExtractor}
|
||||
onDragEnd={vi.fn()}
|
||||
renderItem={renderItem}
|
||||
scrollEnabled={false}
|
||||
useDragHandle={useDragHandle}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getItemActiveState(item: string): string | null {
|
||||
return (
|
||||
container?.querySelector(`[data-testid="item-${item}"]`)?.getAttribute("data-active") ?? null
|
||||
);
|
||||
}
|
||||
|
||||
describe("DraggableList web", () => {
|
||||
it("uses distance activation for default draggable rows", () => {
|
||||
renderList();
|
||||
|
||||
expect(dndKitMocks.useSensor).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
activationConstraint: { distance: 6 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires a held pointer before activating handle-based drags", () => {
|
||||
renderList({ useDragHandle: true });
|
||||
|
||||
expect(dndKitMocks.useSensor).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
activationConstraint: { delay: 250, tolerance: 8 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears active drag state when a drag is cancelled", () => {
|
||||
renderList();
|
||||
|
||||
act(() => {
|
||||
latestDndContextProps?.onDragStart?.({ active: { id: "alpha" } });
|
||||
});
|
||||
expect(getItemActiveState("alpha")).toBe("true");
|
||||
|
||||
act(() => {
|
||||
latestDndContextProps?.onDragCancel?.();
|
||||
});
|
||||
expect(getItemActiveState("alpha")).toBe("false");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useRef, useState, type ReactElement } from "react";
|
||||
import { useCallback, useMemo, useRef, type ReactElement } from "react";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import {
|
||||
DndContext,
|
||||
@@ -8,19 +8,17 @@ import {
|
||||
type Modifier,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
|
||||
import { useWebScrollViewScrollbar } from "./use-web-scrollbar";
|
||||
import { getPointerActivationConstraint, useDragReorderState } from "./drag-reorder";
|
||||
|
||||
export type { DraggableListProps, DraggableRenderItemInfo };
|
||||
|
||||
@@ -30,8 +28,11 @@ const restrictToVerticalAxis: Modifier = ({ transform }) => ({
|
||||
});
|
||||
|
||||
const DND_MODIFIERS = [restrictToVerticalAxis];
|
||||
const DEFAULT_POINTER_ACTIVATION_CONSTRAINT = { distance: 6 };
|
||||
const HANDLE_POINTER_ACTIVATION_CONSTRAINT = { delay: 250, tolerance: 8 };
|
||||
const POINTER_ACTIVATION_CONFIG = {
|
||||
defaultDistance: 6,
|
||||
holdDelayMs: 250,
|
||||
holdTolerance: 8,
|
||||
};
|
||||
|
||||
interface SortableItemProps<T> {
|
||||
id: string;
|
||||
@@ -137,17 +138,21 @@ export function DraggableList<T>({
|
||||
onDragBegin,
|
||||
nestable: _nestable = false,
|
||||
}: DraggableListProps<T>) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [dragItems, setDragItems] = useState<T[] | null>(null);
|
||||
const items = dragItems ?? data;
|
||||
const { activeId, items, handlers } = useDragReorderState({
|
||||
data,
|
||||
keyExtractor,
|
||||
onDragEnd,
|
||||
onDragBegin,
|
||||
});
|
||||
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const scrollbar = useWebScrollViewScrollbar(scrollViewRef, {
|
||||
enabled: showCustomScrollbar,
|
||||
});
|
||||
const pointerActivationConstraint = useDragHandle
|
||||
? HANDLE_POINTER_ACTIVATION_CONSTRAINT
|
||||
: DEFAULT_POINTER_ACTIVATION_CONSTRAINT;
|
||||
const pointerActivationConstraint = getPointerActivationConstraint(
|
||||
useDragHandle,
|
||||
POINTER_ACTIVATION_CONFIG,
|
||||
);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -158,39 +163,6 @@ export function DraggableList<T>({
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
setDragItems(data);
|
||||
setActiveId(String(event.active.id));
|
||||
onDragBegin?.();
|
||||
},
|
||||
[data, onDragBegin],
|
||||
);
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
setActiveId(null);
|
||||
setDragItems(null);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
clearDragState();
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
|
||||
const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id);
|
||||
|
||||
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
onDragEnd(newItems);
|
||||
}
|
||||
}
|
||||
},
|
||||
[clearDragState, items, keyExtractor, onDragEnd],
|
||||
);
|
||||
|
||||
const ids = useMemo(
|
||||
() => items.map((item, index) => keyExtractor(item, index)),
|
||||
[items, keyExtractor],
|
||||
@@ -224,9 +196,9 @@ export function DraggableList<T>({
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={DND_MODIFIERS}
|
||||
onDragStart={handleDragStart}
|
||||
onDragCancel={clearDragState}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={handlers.onDragStart}
|
||||
onDragCancel={handlers.onDragCancel}
|
||||
onDragEnd={handlers.onDragEnd}
|
||||
>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{items.map((item, index) => {
|
||||
@@ -255,9 +227,9 @@ export function DraggableList<T>({
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={DND_MODIFIERS}
|
||||
onDragStart={handleDragStart}
|
||||
onDragCancel={clearDragState}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={handlers.onDragStart}
|
||||
onDragCancel={handlers.onDragCancel}
|
||||
onDragEnd={handlers.onDragEnd}
|
||||
>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{items.map((item, index) => {
|
||||
|
||||
@@ -1158,7 +1158,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
codeText: {
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontSize: theme.fontSize.code,
|
||||
flexShrink: 0,
|
||||
},
|
||||
previewImageScrollContent: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { FileReadResult } from "@server/client/daemon-client";
|
||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -172,15 +172,15 @@ const codeLineStyles = StyleSheet.create((theme) => ({
|
||||
gutterText: {
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.45,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: theme.fontSize.code * 1.45,
|
||||
opacity: 0.4,
|
||||
userSelect: "none",
|
||||
},
|
||||
lineText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.45,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: theme.fontSize.code * 1.45,
|
||||
flex: 1,
|
||||
},
|
||||
}));
|
||||
@@ -216,9 +216,9 @@ function FilePreviewBody({
|
||||
|
||||
const gutterWidth = useMemo(() => {
|
||||
if (!highlightedLines) return 0;
|
||||
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.sm);
|
||||
}, [highlightedLines, theme.fontSize.sm]);
|
||||
const lineHeight = theme.fontSize.sm * 1.45;
|
||||
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.code);
|
||||
}, [highlightedLines, theme.fontSize.code]);
|
||||
const lineHeight = theme.fontSize.code * 1.45;
|
||||
const lineSelection = useMemo(() => {
|
||||
if (!highlightedLines) {
|
||||
return null;
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { Pressable, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { Check, Copy } from "lucide-react-native";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
@@ -46,6 +40,10 @@ function fenceLanguageToExtension(info: string | null | undefined): string | nul
|
||||
return LANGUAGE_ALIASES[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
function stripTerminalFenceNewline(code: string): string {
|
||||
return code.endsWith("\n") ? code.slice(0, -1) : code;
|
||||
}
|
||||
|
||||
// Cross-instance cache for tokenized code blocks. Tokenization is
|
||||
// theme-independent (colors are applied at render time), so the key is just
|
||||
// (language, code). Bounded by entry count — 200 is generous for a chat
|
||||
@@ -87,23 +85,24 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
|
||||
() => splitFenceStyle(inheritedStyles, textStyle),
|
||||
[inheritedStyles, textStyle],
|
||||
);
|
||||
const renderedCode = useMemo(() => stripTerminalFenceNewline(code), [code]);
|
||||
|
||||
const keyedLines = useMemo<KeyedLine[] | null>(() => {
|
||||
const ext = fenceLanguageToExtension(language);
|
||||
if (!ext) return null;
|
||||
const cacheKey = `${ext}:${code}`;
|
||||
const cacheKey = `${ext}:${renderedCode}`;
|
||||
const cached = tokenizationCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
let tokenizedLines: HighlightToken[][];
|
||||
try {
|
||||
tokenizedLines = highlightCode(code, `x.${ext}`);
|
||||
tokenizedLines = highlightCode(renderedCode, `x.${ext}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const result = tokenizedLines.map(toKeyedLine);
|
||||
tokenizationCache.set(cacheKey, result);
|
||||
return result;
|
||||
}, [code, language]);
|
||||
}, [renderedCode, language]);
|
||||
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
@@ -119,18 +118,9 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
{keyedLines ? (
|
||||
<Text style={innerTextStyle}>
|
||||
{keyedLines.map((line, lineIndex) => (
|
||||
<React.Fragment key={line.key}>
|
||||
{lineIndex > 0 ? "\n" : null}
|
||||
{line.tokens.map(({ key, token }) => (
|
||||
<TokenSpan key={key} token={token} />
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Text>
|
||||
<MarkdownTextSpan style={innerTextStyle}>{renderCodeSegments(keyedLines)}</MarkdownTextSpan>
|
||||
) : (
|
||||
<Text style={innerTextStyle}>{code}</Text>
|
||||
<MarkdownTextSpan style={innerTextStyle}>{renderedCode}</MarkdownTextSpan>
|
||||
)}
|
||||
<CopyButton getCode={getCode} visible={controlsVisible} />
|
||||
</View>
|
||||
@@ -157,13 +147,38 @@ function toKeyedLine(tokens: HighlightToken[], lineIndex: number): KeyedLine {
|
||||
};
|
||||
}
|
||||
|
||||
function renderCodeSegments(keyedLines: KeyedLine[]): React.ReactNode[] {
|
||||
const segments: React.ReactNode[] = [];
|
||||
for (let lineIndex = 0; lineIndex < keyedLines.length; lineIndex += 1) {
|
||||
const line = keyedLines[lineIndex];
|
||||
if (lineIndex > 0) {
|
||||
segments.push(<CodeTextSpan key={`${line.key}-newline`} text={"\n"} />);
|
||||
}
|
||||
for (const { key, token } of line.tokens) {
|
||||
segments.push(<TokenSpan key={`${line.key}-${key}`} token={token} />);
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
interface TokenSpanProps {
|
||||
token: HighlightToken;
|
||||
}
|
||||
|
||||
const TokenSpan = React.memo(function TokenSpan({ token }: TokenSpanProps) {
|
||||
if (!token.style) return token.text;
|
||||
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
|
||||
return (
|
||||
<MarkdownTextSpan style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>
|
||||
{token.text}
|
||||
</MarkdownTextSpan>
|
||||
);
|
||||
});
|
||||
|
||||
interface CodeTextSpanProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
const CodeTextSpan = React.memo(function CodeTextSpan({ text }: CodeTextSpanProps) {
|
||||
return <MarkdownTextSpan>{text}</MarkdownTextSpan>;
|
||||
});
|
||||
|
||||
interface SplitStyles {
|
||||
@@ -179,6 +194,7 @@ function splitFenceStyle(inheritedStyles: TextStyle, textStyle: TextStyle): Spli
|
||||
const textOnly: TextStyle = { ...WEB_SELECTABLE };
|
||||
if (fontFamily !== undefined) textOnly.fontFamily = fontFamily;
|
||||
if (fontSize !== undefined) textOnly.fontSize = fontSize;
|
||||
if (fontSize !== undefined) textOnly.lineHeight = Math.round(fontSize * 1.45);
|
||||
if (color !== undefined) textOnly.color = color;
|
||||
return {
|
||||
containerStyle: [box as ViewStyle, CONTAINER_BASE],
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
isKnownEditorTargetId,
|
||||
type EditorTargetId,
|
||||
type KnownEditorTargetId,
|
||||
} from "@server/shared/messages";
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
interface EditorAppIconProps {
|
||||
editorId: EditorTargetId;
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
aggregateSessionEntries,
|
||||
ALL_FILTER_VALUE,
|
||||
buildProviderLabelMap,
|
||||
collectErroredProviderLabels,
|
||||
computeEmptyState,
|
||||
getPromptPreview,
|
||||
getSessionTitle,
|
||||
resolveProvidersToFetch,
|
||||
type SessionsQueryResult,
|
||||
sumFilteredAlreadyImportedCount,
|
||||
} from "@/components/import-session-sheet-view-model";
|
||||
|
||||
function entry(
|
||||
overrides: Partial<FetchRecentProviderSessionEntry> = {},
|
||||
): FetchRecentProviderSessionEntry {
|
||||
return {
|
||||
providerId: "claude",
|
||||
providerLabel: "Claude Code",
|
||||
providerHandleId: "thread-1",
|
||||
cwd: "/repo/paseo",
|
||||
title: null,
|
||||
firstPromptPreview: null,
|
||||
lastPromptPreview: null,
|
||||
lastActivityAt: "2026-04-30T10:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function settled(
|
||||
data: SessionsQueryResult["data"],
|
||||
flags?: Partial<Omit<SessionsQueryResult, "data">>,
|
||||
): SessionsQueryResult {
|
||||
return {
|
||||
data,
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
isPending: false,
|
||||
...flags,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveProvidersToFetch", () => {
|
||||
it("returns null when the daemon does not support provider snapshots", () => {
|
||||
expect(resolveProvidersToFetch(false, [{ provider: "claude" }])).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null while snapshot entries have not loaded yet", () => {
|
||||
expect(resolveProvidersToFetch(true, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns only enabled importable providers", () => {
|
||||
const providers = resolveProvidersToFetch(true, [
|
||||
{ provider: "claude" },
|
||||
{ provider: "codex" },
|
||||
{ provider: "opencode", enabled: false },
|
||||
{ provider: "z-ai" },
|
||||
]);
|
||||
expect(providers).toEqual(["claude", "codex"]);
|
||||
});
|
||||
|
||||
it("returns an empty array when snapshot has no enabled importable providers", () => {
|
||||
const providers = resolveProvidersToFetch(true, [
|
||||
{ provider: "claude", enabled: false },
|
||||
{ provider: "z-ai" },
|
||||
]);
|
||||
expect(providers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildProviderLabelMap", () => {
|
||||
it("returns an empty map when snapshot entries are missing", () => {
|
||||
expect(buildProviderLabelMap(undefined).size).toBe(0);
|
||||
});
|
||||
|
||||
it("indexes labels by provider id, skipping entries without a label", () => {
|
||||
const labels = buildProviderLabelMap([
|
||||
{ provider: "claude", label: "Claude Code" },
|
||||
{ provider: "codex" },
|
||||
{ provider: "z-ai", label: "Z.AI" },
|
||||
]);
|
||||
expect(labels.get("claude")).toBe("Claude Code");
|
||||
expect(labels.get("codex")).toBeUndefined();
|
||||
expect(labels.get("z-ai")).toBe("Z.AI");
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateSessionEntries", () => {
|
||||
it("returns an empty array when no queries have data", () => {
|
||||
expect(aggregateSessionEntries([settled(undefined)])).toEqual([]);
|
||||
});
|
||||
|
||||
it("dedupes by providerId+providerHandleId across query results", () => {
|
||||
const result = aggregateSessionEntries([
|
||||
settled({
|
||||
entries: [
|
||||
entry({ providerHandleId: "thread-1", lastActivityAt: "2026-04-30T10:00:00.000Z" }),
|
||||
],
|
||||
}),
|
||||
settled({
|
||||
entries: [
|
||||
entry({ providerHandleId: "thread-1", lastActivityAt: "2026-04-30T11:00:00.000Z" }),
|
||||
entry({ providerHandleId: "thread-2", lastActivityAt: "2026-04-30T09:00:00.000Z" }),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(result.map((e) => e.providerHandleId)).toEqual(["thread-1", "thread-2"]);
|
||||
});
|
||||
|
||||
it("sorts collected entries by lastActivityAt descending", () => {
|
||||
const result = aggregateSessionEntries([
|
||||
settled({
|
||||
entries: [
|
||||
entry({ providerHandleId: "old", lastActivityAt: "2026-04-29T10:00:00.000Z" }),
|
||||
entry({ providerHandleId: "new", lastActivityAt: "2026-04-30T10:00:00.000Z" }),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(result.map((e) => e.providerHandleId)).toEqual(["new", "old"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sumFilteredAlreadyImportedCount", () => {
|
||||
it("returns 0 when no queries report a filtered count", () => {
|
||||
expect(sumFilteredAlreadyImportedCount([settled({ entries: [] })])).toBe(0);
|
||||
});
|
||||
|
||||
it("sums the filtered already-imported counts across queries", () => {
|
||||
const total = sumFilteredAlreadyImportedCount([
|
||||
settled({ entries: [], filteredAlreadyImportedCount: 2 }),
|
||||
settled({ entries: [], filteredAlreadyImportedCount: 3 }),
|
||||
settled(undefined),
|
||||
]);
|
||||
expect(total).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectErroredProviderLabels", () => {
|
||||
it("returns no labels when no providers are being fetched", () => {
|
||||
expect(collectErroredProviderLabels(null, [], new Map())).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns labels for each errored provider, falling back to provider id", () => {
|
||||
const labels = collectErroredProviderLabels(
|
||||
["claude", "codex"],
|
||||
[settled(undefined, { isError: true }), settled({ entries: [] })],
|
||||
new Map([["claude", "Claude Code"]]),
|
||||
);
|
||||
expect(labels).toEqual(["Claude Code"]);
|
||||
});
|
||||
|
||||
it("uses provider id when the label map has no entry", () => {
|
||||
const labels = collectErroredProviderLabels(
|
||||
["codex"],
|
||||
[settled(undefined, { isError: true })],
|
||||
new Map(),
|
||||
);
|
||||
expect(labels).toEqual(["codex"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSessionTitle", () => {
|
||||
it("prefers the trimmed title", () => {
|
||||
expect(getSessionTitle(entry({ title: " Importable " }))).toBe("Importable");
|
||||
});
|
||||
|
||||
it("falls back to the trimmed first prompt preview when title is empty", () => {
|
||||
expect(getSessionTitle(entry({ title: " ", firstPromptPreview: " Hello " }))).toBe("Hello");
|
||||
});
|
||||
|
||||
it("falls back to Untitled session when both title and first prompt are blank", () => {
|
||||
expect(getSessionTitle(entry({ title: null, firstPromptPreview: " " }))).toBe(
|
||||
"Untitled session",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPromptPreview", () => {
|
||||
it("prefers the trimmed last prompt preview", () => {
|
||||
expect(
|
||||
getPromptPreview(
|
||||
entry({ lastPromptPreview: " later ", firstPromptPreview: " earlier " }),
|
||||
),
|
||||
).toBe("later");
|
||||
});
|
||||
|
||||
it("falls back to the first prompt preview when last is blank", () => {
|
||||
expect(
|
||||
getPromptPreview(entry({ lastPromptPreview: " ", firstPromptPreview: " earlier " })),
|
||||
).toBe("earlier");
|
||||
});
|
||||
|
||||
it("falls back to a placeholder when both prompts are blank", () => {
|
||||
expect(getPromptPreview(entry({ lastPromptPreview: null, firstPromptPreview: null }))).toBe(
|
||||
"No prompt preview",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEmptyState", () => {
|
||||
const baseInputs = {
|
||||
isLoadingSessions: false,
|
||||
allQueriesErrored: false,
|
||||
isQueryingProviders: true,
|
||||
allQueriesSettled: true,
|
||||
selectedProvider: ALL_FILTER_VALUE,
|
||||
aggregatedCount: 0,
|
||||
visibleCount: 0,
|
||||
totalAlreadyImportedCount: 0,
|
||||
providerLabelById: new Map<string, string>(),
|
||||
};
|
||||
|
||||
it("hides the empty state while sessions are still loading", () => {
|
||||
const result = computeEmptyState({ ...baseInputs, isLoadingSessions: true });
|
||||
expect(result.showEmptyState).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the empty state when every query errored", () => {
|
||||
const result = computeEmptyState({ ...baseInputs, allQueriesErrored: true });
|
||||
expect(result.showEmptyState).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the empty state until every provider query has settled", () => {
|
||||
const result = computeEmptyState({ ...baseInputs, allQueriesSettled: false });
|
||||
expect(result.showEmptyState).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the empty state when there are visible entries", () => {
|
||||
const result = computeEmptyState({
|
||||
...baseInputs,
|
||||
aggregatedCount: 2,
|
||||
visibleCount: 2,
|
||||
});
|
||||
expect(result.showEmptyState).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the default no-sessions message when nothing is loaded and nothing is filtered", () => {
|
||||
const result = computeEmptyState(baseInputs);
|
||||
expect(result).toEqual({
|
||||
showEmptyState: true,
|
||||
emptyStateTitle: "No recent sessions to import.",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the already-imported message when imported entries were filtered out", () => {
|
||||
const result = computeEmptyState({
|
||||
...baseInputs,
|
||||
totalAlreadyImportedCount: 4,
|
||||
});
|
||||
expect(result.emptyStateTitle).toBe("All recent sessions are already imported.");
|
||||
});
|
||||
|
||||
it("shows a provider-scoped message when a filter hides aggregated entries", () => {
|
||||
const result = computeEmptyState({
|
||||
...baseInputs,
|
||||
selectedProvider: "claude",
|
||||
aggregatedCount: 3,
|
||||
providerLabelById: new Map([["claude", "Claude Code"]]),
|
||||
});
|
||||
expect(result.emptyStateTitle).toBe("No Claude Code sessions found.");
|
||||
});
|
||||
|
||||
it("falls back to the provider id when the filtered provider lacks a label", () => {
|
||||
const result = computeEmptyState({
|
||||
...baseInputs,
|
||||
selectedProvider: "z-ai",
|
||||
aggregatedCount: 1,
|
||||
});
|
||||
expect(result.emptyStateTitle).toBe("No z-ai sessions found.");
|
||||
});
|
||||
});
|
||||
145
packages/app/src/components/import-session-sheet-view-model.ts
Normal file
145
packages/app/src/components/import-session-sheet-view-model.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { IMPORTABLE_PROVIDERS } from "@getpaseo/protocol/importable-providers";
|
||||
|
||||
export const IMPORTABLE_PROVIDER_IDS: Set<string> = new Set(IMPORTABLE_PROVIDERS);
|
||||
export const PER_PROVIDER_LIMIT = 15;
|
||||
export const ALL_FILTER_VALUE = "__all__";
|
||||
|
||||
export interface SessionsQueryResult {
|
||||
data:
|
||||
| {
|
||||
entries: FetchRecentProviderSessionEntry[];
|
||||
filteredAlreadyImportedCount?: number;
|
||||
}
|
||||
| undefined;
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
export function resolveProvidersToFetch(
|
||||
supportsSnapshot: boolean,
|
||||
snapshotEntries: ReadonlyArray<{ provider: string; enabled?: boolean }> | undefined,
|
||||
): AgentProvider[] | null {
|
||||
// COMPAT(providersSnapshot): the import-recent-sessions feature ships alongside
|
||||
// providersSnapshot (v0.1.48, 2026-04-05). Daemons older than that lack both —
|
||||
// we render an "update host" empty state instead of degrading. Drop this gate
|
||||
// when the supported daemon floor is >= v0.1.48 (target: 2026-10-05).
|
||||
if (!supportsSnapshot) return null;
|
||||
if (!snapshotEntries) return null;
|
||||
return snapshotEntries
|
||||
.filter((entry) => IMPORTABLE_PROVIDER_IDS.has(entry.provider) && entry.enabled !== false)
|
||||
.map((entry) => entry.provider);
|
||||
}
|
||||
|
||||
export function buildProviderLabelMap(
|
||||
snapshotEntries: ReadonlyArray<{ provider: string; label?: string }> | undefined,
|
||||
): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
if (!snapshotEntries) return map;
|
||||
for (const entry of snapshotEntries) {
|
||||
if (entry.label) {
|
||||
map.set(entry.provider, entry.label);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function aggregateSessionEntries(
|
||||
queries: ReadonlyArray<SessionsQueryResult>,
|
||||
): FetchRecentProviderSessionEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const collected: FetchRecentProviderSessionEntry[] = [];
|
||||
for (const query of queries) {
|
||||
if (!query.data) continue;
|
||||
for (const entry of query.data.entries) {
|
||||
const key = `${entry.providerId}:${entry.providerHandleId}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
collected.push(entry);
|
||||
}
|
||||
}
|
||||
collected.sort(
|
||||
(a, b) => new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime(),
|
||||
);
|
||||
return collected;
|
||||
}
|
||||
|
||||
export function sumFilteredAlreadyImportedCount(
|
||||
queries: ReadonlyArray<SessionsQueryResult>,
|
||||
): number {
|
||||
let total = 0;
|
||||
for (const query of queries) {
|
||||
total += query.data?.filteredAlreadyImportedCount ?? 0;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export function collectErroredProviderLabels(
|
||||
providersToFetch: AgentProvider[] | null,
|
||||
queries: ReadonlyArray<SessionsQueryResult>,
|
||||
providerLabelById: ReadonlyMap<string, string>,
|
||||
): string[] {
|
||||
if (providersToFetch === null) return [];
|
||||
const labels: string[] = [];
|
||||
for (let index = 0; index < queries.length; index++) {
|
||||
if (queries[index]?.isError) {
|
||||
const provider = providersToFetch[index];
|
||||
labels.push(providerLabelById.get(provider) ?? provider);
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
export function getSessionTitle(entry: FetchRecentProviderSessionEntry): string {
|
||||
const title = entry.title?.trim();
|
||||
if (title) {
|
||||
return title;
|
||||
}
|
||||
const firstPromptPreview = entry.firstPromptPreview?.trim();
|
||||
if (firstPromptPreview) {
|
||||
return firstPromptPreview;
|
||||
}
|
||||
return "Untitled session";
|
||||
}
|
||||
|
||||
export function getPromptPreview(entry: FetchRecentProviderSessionEntry): string {
|
||||
return entry.lastPromptPreview?.trim() || entry.firstPromptPreview?.trim() || "No prompt preview";
|
||||
}
|
||||
|
||||
export interface EmptyStateInputs {
|
||||
isLoadingSessions: boolean;
|
||||
allQueriesErrored: boolean;
|
||||
isQueryingProviders: boolean;
|
||||
allQueriesSettled: boolean;
|
||||
selectedProvider: string;
|
||||
aggregatedCount: number;
|
||||
visibleCount: number;
|
||||
totalAlreadyImportedCount: number;
|
||||
providerLabelById: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
export function computeEmptyState(input: EmptyStateInputs): {
|
||||
showEmptyState: boolean;
|
||||
emptyStateTitle: string;
|
||||
} {
|
||||
const showEmptyState =
|
||||
!input.isLoadingSessions &&
|
||||
!input.allQueriesErrored &&
|
||||
input.isQueryingProviders &&
|
||||
input.allQueriesSettled &&
|
||||
input.visibleCount === 0;
|
||||
if (!showEmptyState) {
|
||||
return { showEmptyState, emptyStateTitle: "" };
|
||||
}
|
||||
const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0;
|
||||
if (isFilteredEmpty) {
|
||||
const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider;
|
||||
return { showEmptyState, emptyStateTitle: `No ${label} sessions found.` };
|
||||
}
|
||||
if (input.totalAlreadyImportedCount > 0) {
|
||||
return { showEmptyState, emptyStateTitle: "All recent sessions are already imported." };
|
||||
}
|
||||
return { showEmptyState, emptyStateTitle: "No recent sessions to import." };
|
||||
}
|
||||
@@ -4,8 +4,11 @@
|
||||
import React, { type ReactNode } from "react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/client/daemon-client";
|
||||
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
|
||||
import type {
|
||||
DaemonClient,
|
||||
FetchRecentProviderSessionEntry,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Pressable, type PressableStateCallbackType, ScrollView, Text, View } from "react-native";
|
||||
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/client/daemon-client";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import { IMPORTABLE_PROVIDERS } from "@server/shared/importable-providers";
|
||||
import type {
|
||||
DaemonClient,
|
||||
FetchRecentProviderSessionEntry,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { Inbox, RotateCw } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
@@ -12,12 +14,21 @@ import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/s
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import {
|
||||
aggregateSessionEntries,
|
||||
ALL_FILTER_VALUE,
|
||||
buildProviderLabelMap,
|
||||
collectErroredProviderLabels,
|
||||
computeEmptyState,
|
||||
getPromptPreview,
|
||||
getSessionTitle,
|
||||
PER_PROVIDER_LIMIT,
|
||||
resolveProvidersToFetch,
|
||||
sumFilteredAlreadyImportedCount,
|
||||
} from "@/components/import-session-sheet-view-model";
|
||||
|
||||
const IMPORTABLE_PROVIDER_IDS: Set<string> = new Set(IMPORTABLE_PROVIDERS);
|
||||
const PER_PROVIDER_LIMIT = 15;
|
||||
const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"];
|
||||
const DISABLED_ACCESSIBILITY_STATE = { disabled: true };
|
||||
const ALL_FILTER_VALUE = "__all__";
|
||||
|
||||
type RecentProviderSessionsClient = Pick<
|
||||
DaemonClient,
|
||||
@@ -46,41 +57,6 @@ interface SessionsQueryConfig {
|
||||
queryFn: () => Promise<RecentSessionsResponse>;
|
||||
}
|
||||
|
||||
interface SessionsQueryResult {
|
||||
data: RecentSessionsResponse | undefined;
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
}
|
||||
|
||||
function resolveProvidersToFetch(
|
||||
supportsSnapshot: boolean,
|
||||
snapshotEntries: ReadonlyArray<{ provider: string; enabled?: boolean }> | undefined,
|
||||
): AgentProvider[] | null {
|
||||
// COMPAT(providersSnapshot): the import-recent-sessions feature ships alongside
|
||||
// providersSnapshot (v0.1.48, 2026-04-05). Daemons older than that lack both —
|
||||
// we render an "update host" empty state instead of degrading. Drop this gate
|
||||
// when the supported daemon floor is >= v0.1.48 (target: 2026-10-05).
|
||||
if (!supportsSnapshot) return null;
|
||||
if (!snapshotEntries) return null;
|
||||
return snapshotEntries
|
||||
.filter((entry) => IMPORTABLE_PROVIDER_IDS.has(entry.provider) && entry.enabled !== false)
|
||||
.map((entry) => entry.provider);
|
||||
}
|
||||
|
||||
function buildProviderLabelMap(
|
||||
snapshotEntries: ReadonlyArray<{ provider: string; label?: string }> | undefined,
|
||||
): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
if (!snapshotEntries) return map;
|
||||
for (const entry of snapshotEntries) {
|
||||
if (entry.label) {
|
||||
map.set(entry.provider, entry.label);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function buildSessionsQueriesConfig(args: {
|
||||
providersToFetch: AgentProvider[] | null;
|
||||
sessionsQueryRoot: ReadonlyArray<string | null>;
|
||||
@@ -107,66 +83,6 @@ function buildSessionsQueriesConfig(args: {
|
||||
}));
|
||||
}
|
||||
|
||||
function aggregateSessionEntries(
|
||||
queries: ReadonlyArray<SessionsQueryResult>,
|
||||
): FetchRecentProviderSessionEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const collected: FetchRecentProviderSessionEntry[] = [];
|
||||
for (const query of queries) {
|
||||
if (!query.data) continue;
|
||||
for (const entry of query.data.entries) {
|
||||
const key = `${entry.providerId}:${entry.providerHandleId}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
collected.push(entry);
|
||||
}
|
||||
}
|
||||
collected.sort(
|
||||
(a, b) => new Date(b.lastActivityAt).getTime() - new Date(a.lastActivityAt).getTime(),
|
||||
);
|
||||
return collected;
|
||||
}
|
||||
|
||||
function sumFilteredAlreadyImportedCount(queries: ReadonlyArray<SessionsQueryResult>): number {
|
||||
let total = 0;
|
||||
for (const query of queries) {
|
||||
total += query.data?.filteredAlreadyImportedCount ?? 0;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function collectErroredProviderLabels(
|
||||
providersToFetch: AgentProvider[] | null,
|
||||
queries: ReadonlyArray<SessionsQueryResult>,
|
||||
providerLabelById: ReadonlyMap<string, string>,
|
||||
): string[] {
|
||||
if (providersToFetch === null) return [];
|
||||
const labels: string[] = [];
|
||||
for (let index = 0; index < queries.length; index++) {
|
||||
if (queries[index]?.isError) {
|
||||
const provider = providersToFetch[index];
|
||||
labels.push(providerLabelById.get(provider) ?? provider);
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
function getSessionTitle(entry: FetchRecentProviderSessionEntry): string {
|
||||
const title = entry.title?.trim();
|
||||
if (title) {
|
||||
return title;
|
||||
}
|
||||
const firstPromptPreview = entry.firstPromptPreview?.trim();
|
||||
if (firstPromptPreview) {
|
||||
return firstPromptPreview;
|
||||
}
|
||||
return "Untitled session";
|
||||
}
|
||||
|
||||
function getPromptPreview(entry: FetchRecentProviderSessionEntry): string {
|
||||
return entry.lastPromptPreview?.trim() || entry.firstPromptPreview?.trim() || "No prompt preview";
|
||||
}
|
||||
|
||||
interface SheetStatusMessagesProps {
|
||||
isClientReady: boolean;
|
||||
isSnapshotUnsupported: boolean;
|
||||
@@ -219,42 +135,6 @@ function SheetStatusMessages({
|
||||
);
|
||||
}
|
||||
|
||||
interface EmptyStateInputs {
|
||||
isLoadingSessions: boolean;
|
||||
allQueriesErrored: boolean;
|
||||
isQueryingProviders: boolean;
|
||||
allQueriesSettled: boolean;
|
||||
selectedProvider: string;
|
||||
aggregatedCount: number;
|
||||
visibleCount: number;
|
||||
totalAlreadyImportedCount: number;
|
||||
providerLabelById: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
function computeEmptyState(input: EmptyStateInputs): {
|
||||
showEmptyState: boolean;
|
||||
emptyStateTitle: string;
|
||||
} {
|
||||
const showEmptyState =
|
||||
!input.isLoadingSessions &&
|
||||
!input.allQueriesErrored &&
|
||||
input.isQueryingProviders &&
|
||||
input.allQueriesSettled &&
|
||||
input.visibleCount === 0;
|
||||
if (!showEmptyState) {
|
||||
return { showEmptyState, emptyStateTitle: "" };
|
||||
}
|
||||
const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0;
|
||||
if (isFilteredEmpty) {
|
||||
const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider;
|
||||
return { showEmptyState, emptyStateTitle: `No ${label} sessions found.` };
|
||||
}
|
||||
if (input.totalAlreadyImportedCount > 0) {
|
||||
return { showEmptyState, emptyStateTitle: "All recent sessions are already imported." };
|
||||
}
|
||||
return { showEmptyState, emptyStateTitle: "No recent sessions to import." };
|
||||
}
|
||||
|
||||
function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) {
|
||||
const { theme } = useUnistyles();
|
||||
const pressableStyle = useCallback(
|
||||
|
||||
37
packages/app/src/components/markdown-text.android.tsx
Normal file
37
packages/app/src/components/markdown-text.android.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
|
||||
interface MarkdownTextSpanProps {
|
||||
style?: StyleProp<TextStyle>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Android's <Text selectable> enables per-text-node selection natively. Each
|
||||
// sibling Text is its own selection scope — drag can't span across siblings
|
||||
// (that requires a single UITextView ancestor and is iOS-only).
|
||||
export function MarkdownTextSpan({ style, children }: MarkdownTextSpanProps) {
|
||||
return (
|
||||
<Text selectable style={style}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownParagraphViewProps {
|
||||
paragraphStyle: ViewStyle;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
|
||||
|
||||
// Paragraph stays a <View>, not a <Text>, for layout fidelity. RN Android's
|
||||
// text engine *does* accept inline View children (TextInlineViewPlaceholderSpan
|
||||
// in ReactBaseTextShadowNode), so this isn't a crash-avoidance choice — but
|
||||
// inline-placeholder spans collapse block-level children (e.g. paragraph
|
||||
// images) into one-character placeholders, which destroys image row layout.
|
||||
// <View> preserves the original block layout; the trade-off is no cross-span
|
||||
// selection on Android (a UITextView-style trick has no Android equivalent).
|
||||
export function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphViewProps) {
|
||||
const style = useMemo(() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET], [paragraphStyle]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
1
packages/app/src/components/markdown-text.d.ts
vendored
Normal file
1
packages/app/src/components/markdown-text.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./markdown-text.ios";
|
||||
45
packages/app/src/components/markdown-text.ios.tsx
Normal file
45
packages/app/src/components/markdown-text.ios.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
|
||||
import { UITextView } from "react-native-uitextview";
|
||||
|
||||
interface MarkdownTextSpanProps {
|
||||
style?: StyleProp<TextStyle>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Inline span backed by UITextView so iOS gets native word-selection handles.
|
||||
// Used inside MarkdownParagraphView (which is also a UITextView on iOS); the
|
||||
// library's TextAncestorContext hoists these into UITextViewChild nodes so
|
||||
// selection drags can cross sibling spans (e.g. plain text → **bold** → code).
|
||||
export function MarkdownTextSpan({ style, children }: MarkdownTextSpanProps) {
|
||||
return (
|
||||
<UITextView uiTextView selectable style={style}>
|
||||
{children}
|
||||
</UITextView>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownParagraphViewProps {
|
||||
paragraphStyle: ViewStyle;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
|
||||
|
||||
// iOS-only: paragraph wraps in UITextView so the entire paragraph is one
|
||||
// native text view. That's what unlocks cross-inline drag selection — handles
|
||||
// can span every MarkdownTextSpan child inside this paragraph.
|
||||
// ViewStyle is structurally compatible with the layout props paragraphs use
|
||||
// (margin, padding, alignment); the cast lets the existing paragraphStyle
|
||||
// flow through unchanged.
|
||||
export function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphViewProps) {
|
||||
const style = useMemo(
|
||||
() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET] as StyleProp<TextStyle>,
|
||||
[paragraphStyle],
|
||||
);
|
||||
return (
|
||||
<UITextView uiTextView selectable style={style}>
|
||||
{children}
|
||||
</UITextView>
|
||||
);
|
||||
}
|
||||
31
packages/app/src/components/markdown-text.web.tsx
Normal file
31
packages/app/src/components/markdown-text.web.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
|
||||
interface MarkdownTextSpanProps {
|
||||
style?: StyleProp<TextStyle>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// react-native-web renders Text as <span>/<div> with `user-select: text`
|
||||
// already applied via markdownStyleMapping. The web bundle must not import
|
||||
// react-native-uitextview: its transitive import of codegenNativeComponent
|
||||
// pulls in setUpReactDevTools, which doesn't resolve under Metro's web
|
||||
// target in dev mode.
|
||||
export function MarkdownTextSpan({ style, children }: MarkdownTextSpanProps) {
|
||||
return <Text style={style}>{children}</Text>;
|
||||
}
|
||||
|
||||
interface MarkdownParagraphViewProps {
|
||||
paragraphStyle: ViewStyle;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
|
||||
|
||||
// Same shape as Android — paragraph is a View so block-level children (images)
|
||||
// keep their natural layout. Web text selection already spans nested inline
|
||||
// elements via CSS user-select, so no UITextView equivalent is needed.
|
||||
export function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphViewProps) {
|
||||
const style = useMemo(() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET], [paragraphStyle]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ViewStyle,
|
||||
type TextStyle,
|
||||
} from "react-native";
|
||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import * as React from "react";
|
||||
import {
|
||||
useState,
|
||||
@@ -49,7 +50,7 @@ import {
|
||||
FileSymlink,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { SPACING, type Theme } from "@/styles/theme";
|
||||
import { type Theme } from "@/styles/theme";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -63,16 +64,17 @@ import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "reac
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import { buildToolCallPresentation } from "@/tool-calls/presentation";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import { getMarkdownListMarker, getMarkdownNextSiblingType } from "@/utils/markdown-list";
|
||||
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||||
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
|
||||
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
|
||||
import {
|
||||
getAssistantImageLoadStateFromMetadata,
|
||||
getAssistantImageMetadata,
|
||||
@@ -100,9 +102,9 @@ import {
|
||||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
||||
import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
|
||||
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
|
||||
export type { InlinePathTarget } from "@/assistant-file-links";
|
||||
@@ -410,13 +412,10 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
copyButton: {
|
||||
alignSelf: "center",
|
||||
width: 24,
|
||||
height: 24,
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
padding: theme.spacing[1],
|
||||
paddingTop: theme.spacing[1],
|
||||
marginTop: 0,
|
||||
marginRight: -theme.spacing[1],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
trailingRow: {
|
||||
alignSelf: "flex-end",
|
||||
@@ -1151,7 +1150,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({
|
||||
return;
|
||||
}
|
||||
|
||||
await writeMarkdownToRichClipboard(content);
|
||||
await writeMarkdownToRichClipboard(content, getDefaultMarkdownClipboardEnvironment());
|
||||
setCopied(true);
|
||||
|
||||
if (copyTimeoutRef.current) {
|
||||
@@ -1527,7 +1526,7 @@ function MarkdownInheritedText({
|
||||
() => [inheritedStyles, textStyle, overrideStyle],
|
||||
[inheritedStyles, textStyle, overrideStyle],
|
||||
);
|
||||
return <Text style={style}>{children}</Text>;
|
||||
return <MarkdownTextSpan style={style}>{children}</MarkdownTextSpan>;
|
||||
}
|
||||
|
||||
interface MarkdownListItemContentProps {
|
||||
@@ -1542,51 +1541,14 @@ function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemCon
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
interface MarkdownParagraphViewProps {
|
||||
paragraphStyle: ViewStyle;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
|
||||
|
||||
function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphViewProps) {
|
||||
const style = useMemo(() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET], [paragraphStyle]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
// List spacing in markdown:
|
||||
// - p -> list and list -> p use a slightly larger gap than p -> p, so lists
|
||||
// read as their own section against surrounding prose.
|
||||
// - list -> list keeps the normal p-to-p gap; back-to-back lists are
|
||||
// continuous content, not section breaks.
|
||||
//
|
||||
// Paragraph's marginBottom is SPACING[3] = 12 (and marginTop is 0). To produce
|
||||
// 16px gaps on p<->list transitions and 12px on list<->list, we add a constant
|
||||
// marginTop on lists (4) and switch marginBottom by next-sibling type:
|
||||
// p -> list = p.marginBottom(12) + list.marginTop(4) = 16
|
||||
// list -> p = list.marginBottom(16) + p.marginTop(0) = 16
|
||||
// list -> list = list.marginBottom(8) + list.marginTop(4) = 12
|
||||
const MARKDOWN_LIST_MARGIN_TOP = SPACING[1]; // 4
|
||||
const MARKDOWN_LIST_MARGIN_BOTTOM_TO_PROSE = SPACING[4]; // 16
|
||||
const MARKDOWN_LIST_MARGIN_BOTTOM_TO_LIST = SPACING[2]; // 8
|
||||
|
||||
function getMarkdownListContextMarginBottom(node: ASTNode, parent: ASTNode[]): number {
|
||||
const nextType = getMarkdownNextSiblingType(node, parent);
|
||||
const nextIsList = nextType === "bullet_list" || nextType === "ordered_list";
|
||||
return nextIsList ? MARKDOWN_LIST_MARGIN_BOTTOM_TO_LIST : MARKDOWN_LIST_MARGIN_BOTTOM_TO_PROSE;
|
||||
}
|
||||
|
||||
interface MarkdownListViewProps {
|
||||
baseStyle: ViewStyle;
|
||||
marginBottom: number;
|
||||
spacing: { marginTop: number; marginBottom: number };
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownListView({ baseStyle, marginBottom, children }: MarkdownListViewProps) {
|
||||
const style = useMemo(
|
||||
() => [baseStyle, { marginTop: MARKDOWN_LIST_MARGIN_TOP, marginBottom }],
|
||||
[baseStyle, marginBottom],
|
||||
);
|
||||
function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProps) {
|
||||
const style = useMemo(() => [baseStyle, spacing], [baseStyle, spacing]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
@@ -1748,7 +1710,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.bullet_list}
|
||||
marginBottom={getMarkdownListContextMarginBottom(node, parent)}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
@@ -1762,7 +1724,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.ordered_list}
|
||||
marginBottom={getMarkdownListContextMarginBottom(node, parent)}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
@@ -2013,7 +1975,7 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
metadataText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontSize: theme.fontSize.code,
|
||||
fontFamily: Fonts.mono,
|
||||
lineHeight: 16,
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { HostProfile } from "@/types/host-connection";
|
||||
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 { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
17
packages/app/src/components/provider-icon-name.test.ts
Normal file
17
packages/app/src/components/provider-icon-name.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveProviderIconName } from "./provider-icon-name";
|
||||
|
||||
describe("resolveProviderIconName", () => {
|
||||
it("returns the built-in identifier for known provider ids", () => {
|
||||
expect(resolveProviderIconName("kiro")).toEqual({ kind: "builtin", id: "kiro" });
|
||||
expect(resolveProviderIconName("claude")).toEqual({ kind: "builtin", id: "claude" });
|
||||
});
|
||||
|
||||
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
|
||||
expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" });
|
||||
});
|
||||
|
||||
it("falls back to the bot icon for unknown custom providers", () => {
|
||||
expect(resolveProviderIconName("custom-claude-profile")).toEqual({ kind: "bot" });
|
||||
});
|
||||
});
|
||||
31
packages/app/src/components/provider-icon-name.ts
Normal file
31
packages/app/src/components/provider-icon-name.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
|
||||
|
||||
export type BuiltinProviderIconName = "claude" | "codex" | "copilot" | "kiro" | "opencode" | "pi";
|
||||
|
||||
export type ProviderIconName =
|
||||
| { kind: "builtin"; id: BuiltinProviderIconName }
|
||||
| { kind: "catalog"; id: string }
|
||||
| { kind: "bot" };
|
||||
|
||||
const BUILTIN_PROVIDER_IDS: ReadonlySet<BuiltinProviderIconName> = new Set([
|
||||
"claude",
|
||||
"codex",
|
||||
"copilot",
|
||||
"kiro",
|
||||
"opencode",
|
||||
"pi",
|
||||
]);
|
||||
|
||||
const CATALOG_ICON_PROVIDER_IDS: ReadonlySet<string> = new Set(
|
||||
ACP_PROVIDER_CATALOG.flatMap((entry) => (entry.iconSvg ? [entry.id] : [])),
|
||||
);
|
||||
|
||||
export function resolveProviderIconName(provider: string): ProviderIconName {
|
||||
if (BUILTIN_PROVIDER_IDS.has(provider as BuiltinProviderIconName)) {
|
||||
return { kind: "builtin", id: provider as BuiltinProviderIconName };
|
||||
}
|
||||
if (CATALOG_ICON_PROVIDER_IDS.has(provider)) {
|
||||
return { kind: "catalog", id: provider };
|
||||
}
|
||||
return { kind: "bot" };
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("lucide-react-native", () => ({
|
||||
Bot: function Bot() {
|
||||
return null;
|
||||
},
|
||||
PackagePlus: function PackagePlus() {
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
import { Bot, PackagePlus } from "lucide-react-native";
|
||||
import { getProviderIcon } from "./provider-icons";
|
||||
|
||||
describe("getProviderIcon", () => {
|
||||
it("keeps built-in provider icons", () => {
|
||||
expect(getProviderIcon("kiro")).toBe(PackagePlus);
|
||||
});
|
||||
|
||||
it("uses vendored ACP catalog icons for catalog provider ids", () => {
|
||||
const icon = getProviderIcon("amp-acp");
|
||||
|
||||
expect(icon).not.toBe(Bot);
|
||||
expect(getProviderIcon("amp-acp")).toBe(icon);
|
||||
});
|
||||
|
||||
it("falls back to the robot icon for unknown custom providers", () => {
|
||||
expect(getProviderIcon("custom-claude-profile")).toBe(Bot);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,10 @@ import { CopilotIcon } from "@/components/icons/copilot-icon";
|
||||
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
|
||||
import { PiIcon } from "@/components/icons/pi-icon";
|
||||
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
|
||||
import {
|
||||
resolveProviderIconName,
|
||||
type BuiltinProviderIconName,
|
||||
} from "@/components/provider-icon-name";
|
||||
|
||||
export interface ProviderIconProps {
|
||||
size: number;
|
||||
@@ -15,7 +19,7 @@ export interface ProviderIconProps {
|
||||
|
||||
export type ProviderIconComponent = ComponentType<ProviderIconProps>;
|
||||
|
||||
const PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
|
||||
const BUILTIN_PROVIDER_ICONS: Record<BuiltinProviderIconName, ProviderIconComponent> = {
|
||||
claude: ClaudeIcon as unknown as ProviderIconComponent,
|
||||
codex: CodexIcon as unknown as ProviderIconComponent,
|
||||
copilot: CopilotIcon as unknown as ProviderIconComponent,
|
||||
@@ -42,22 +46,24 @@ function createCatalogIcon(provider: string, iconSvg: string): ProviderIconCompo
|
||||
return CatalogProviderIcon;
|
||||
}
|
||||
|
||||
function getCatalogProviderIcon(provider: string): ProviderIconComponent | undefined {
|
||||
function getCatalogProviderIcon(provider: string): ProviderIconComponent {
|
||||
const cached = catalogIconComponents.get(provider);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const iconSvg = CATALOG_ICON_SVGS.get(provider);
|
||||
if (!iconSvg) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const iconSvg = CATALOG_ICON_SVGS.get(provider) ?? "";
|
||||
const icon = createCatalogIcon(provider, iconSvg);
|
||||
catalogIconComponents.set(provider, icon);
|
||||
return icon;
|
||||
}
|
||||
|
||||
export function getProviderIcon(provider: string): ProviderIconComponent {
|
||||
return PROVIDER_ICONS[provider] ?? getCatalogProviderIcon(provider) ?? Bot;
|
||||
const name = resolveProviderIconName(provider);
|
||||
if (name.kind === "builtin") {
|
||||
return BUILTIN_PROVIDER_ICONS[name.id];
|
||||
}
|
||||
if (name.kind === "catalog") {
|
||||
return getCatalogProviderIcon(name.id);
|
||||
}
|
||||
return Bot;
|
||||
}
|
||||
|
||||
26
packages/app/src/components/provider-settings-host.tsx
Normal file
26
packages/app/src/components/provider-settings-host.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useCallback } from "react";
|
||||
import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
|
||||
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||
|
||||
export function ProviderSettingsHost() {
|
||||
const serverId = useProviderSettingsStore((state) => state.serverId);
|
||||
const provider = useProviderSettingsStore((state) => state.provider);
|
||||
const close = useProviderSettingsStore((state) => state.close);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
close();
|
||||
}, [close]);
|
||||
|
||||
if (!serverId || !provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProviderDiagnosticSheet
|
||||
provider={provider}
|
||||
serverId={serverId}
|
||||
visible
|
||||
onClose={handleClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
79
packages/app/src/components/question-form-card-core.test.ts
Normal file
79
packages/app/src/components/question-form-card-core.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
areQuestionsAnswered,
|
||||
buildQuestionFormAnswers,
|
||||
parseQuestionFormQuestions,
|
||||
questionShowsTextInput,
|
||||
resolveDismissLabel,
|
||||
shouldSubmitEmptyOnDismiss,
|
||||
} from "./question-form-card-core";
|
||||
|
||||
describe("question form card core", () => {
|
||||
test("treats optional input prompts as skippable empty answers", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
{
|
||||
question: "Optional comment?",
|
||||
header: "Response",
|
||||
options: [],
|
||||
multiSelect: false,
|
||||
placeholder: "Optional comment (press Enter to skip)...",
|
||||
allowEmpty: true,
|
||||
dismissLabel: "Skip",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!questions) throw new Error("questions did not parse");
|
||||
expect(areQuestionsAnswered(questions, {}, {})).toBe(true);
|
||||
expect(buildQuestionFormAnswers(questions, {}, {})).toEqual({ Response: "" });
|
||||
expect(shouldSubmitEmptyOnDismiss(questions)).toBe(true);
|
||||
expect(resolveDismissLabel(questions)).toBe("Skip");
|
||||
});
|
||||
|
||||
test("requires a selection for option-only questions", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
{
|
||||
question: "Pick one",
|
||||
header: "Response",
|
||||
options: [{ label: "A" }, { label: "B" }],
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!questions) throw new Error("questions did not parse");
|
||||
const [question] = questions;
|
||||
if (!question) throw new Error("question missing");
|
||||
expect(questionShowsTextInput(question)).toBe(false);
|
||||
expect(areQuestionsAnswered(questions, {}, { 0: "freeform" })).toBe(false);
|
||||
expect(areQuestionsAnswered(questions, { 0: new Set([1]) }, {})).toBe(true);
|
||||
expect(buildQuestionFormAnswers(questions, { 0: new Set([1]) }, {})).toEqual({
|
||||
Response: "B",
|
||||
});
|
||||
});
|
||||
|
||||
test("shows text input for explicit other questions", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
{
|
||||
question: "Pick or type",
|
||||
header: "Response",
|
||||
options: [{ label: "A" }],
|
||||
isOther: true,
|
||||
multiSelect: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!questions) throw new Error("questions did not parse");
|
||||
const [question] = questions;
|
||||
if (!question) throw new Error("question missing");
|
||||
expect(questionShowsTextInput(question)).toBe(true);
|
||||
expect(areQuestionsAnswered(questions, {}, { 0: "custom" })).toBe(true);
|
||||
expect(buildQuestionFormAnswers(questions, {}, { 0: "custom" })).toEqual({
|
||||
Response: "custom",
|
||||
});
|
||||
});
|
||||
});
|
||||
143
packages/app/src/components/question-form-card-core.ts
Normal file
143
packages/app/src/components/question-form-card-core.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
export interface QuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface QuestionFormQuestion {
|
||||
question: string;
|
||||
header: string;
|
||||
options: QuestionOption[];
|
||||
multiSelect: boolean;
|
||||
allowOther: boolean;
|
||||
allowEmpty: boolean;
|
||||
placeholder?: string;
|
||||
dismissLabel?: string;
|
||||
}
|
||||
|
||||
export type QuestionSelections = Record<number, ReadonlySet<number>>;
|
||||
export type QuestionOtherTexts = Record<number, string>;
|
||||
|
||||
function readOptionalString(record: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = record[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
export function parseQuestionFormQuestions(input: unknown): QuestionFormQuestion[] | null {
|
||||
if (
|
||||
typeof input !== "object" ||
|
||||
input === null ||
|
||||
!("questions" in input) ||
|
||||
!Array.isArray((input as Record<string, unknown>).questions)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const raw = (input as Record<string, unknown>).questions as unknown[];
|
||||
const questions: QuestionFormQuestion[] = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item !== "object" || item === null) return null;
|
||||
const q = item as Record<string, unknown>;
|
||||
if (typeof q.question !== "string" || typeof q.header !== "string") return null;
|
||||
if (!Array.isArray(q.options)) return null;
|
||||
const options: QuestionOption[] = [];
|
||||
for (const opt of q.options as unknown[]) {
|
||||
if (typeof opt !== "object" || opt === null) return null;
|
||||
const o = opt as Record<string, unknown>;
|
||||
if (typeof o.label !== "string") return null;
|
||||
options.push({
|
||||
label: o.label,
|
||||
description: typeof o.description === "string" ? o.description : undefined,
|
||||
});
|
||||
}
|
||||
questions.push({
|
||||
question: q.question,
|
||||
header: q.header,
|
||||
options,
|
||||
multiSelect: q.multiSelect === true,
|
||||
allowOther: q.allowOther === true || q.isOther === true,
|
||||
allowEmpty: q.allowEmpty === true,
|
||||
placeholder: readOptionalString(q, "placeholder"),
|
||||
dismissLabel: readOptionalString(q, "dismissLabel"),
|
||||
});
|
||||
}
|
||||
return questions.length > 0 ? questions : null;
|
||||
}
|
||||
|
||||
export function questionShowsTextInput(question: QuestionFormQuestion): boolean {
|
||||
return question.options.length === 0 || question.allowOther;
|
||||
}
|
||||
|
||||
export function isQuestionAnswered(
|
||||
question: QuestionFormQuestion,
|
||||
qIndex: number,
|
||||
selections: QuestionSelections,
|
||||
otherTexts: QuestionOtherTexts,
|
||||
): boolean {
|
||||
const selected = selections[qIndex];
|
||||
if (selected && selected.size > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!questionShowsTextInput(question)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const otherText = otherTexts[qIndex]?.trim();
|
||||
if (otherText && otherText.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return question.allowEmpty;
|
||||
}
|
||||
|
||||
export function areQuestionsAnswered(
|
||||
questions: QuestionFormQuestion[] | null,
|
||||
selections: QuestionSelections,
|
||||
otherTexts: QuestionOtherTexts,
|
||||
): boolean {
|
||||
return (
|
||||
questions?.every((question, qIndex) =>
|
||||
isQuestionAnswered(question, qIndex, selections, otherTexts),
|
||||
) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
export function buildQuestionFormAnswers(
|
||||
questions: QuestionFormQuestion[],
|
||||
selections: QuestionSelections,
|
||||
otherTexts: QuestionOtherTexts,
|
||||
): Record<string, string> {
|
||||
const answers: Record<string, string> = {};
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i];
|
||||
const selected = selections[i];
|
||||
const otherText = otherTexts[i]?.trim();
|
||||
|
||||
if (questionShowsTextInput(q)) {
|
||||
if (otherText && otherText.length > 0) {
|
||||
answers[q.header] = otherText;
|
||||
continue;
|
||||
}
|
||||
if (q.allowEmpty && q.options.length === 0) {
|
||||
answers[q.header] = "";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (selected && selected.size > 0) {
|
||||
const labels = Array.from(selected).map((idx) => q.options[idx].label);
|
||||
answers[q.header] = labels.join(", ");
|
||||
}
|
||||
}
|
||||
return answers;
|
||||
}
|
||||
|
||||
export function shouldSubmitEmptyOnDismiss(questions: QuestionFormQuestion[]): boolean {
|
||||
return (
|
||||
questions.length > 0 &&
|
||||
questions.every((question) => question.allowEmpty && question.options.length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDismissLabel(questions: QuestionFormQuestion[]): string {
|
||||
return questions.find((question) => question.dismissLabel)?.dismissLabel ?? "Dismiss";
|
||||
}
|
||||
@@ -11,56 +11,18 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, CircleHelp, X } from "lucide-react-native";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
interface QuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface Question {
|
||||
question: string;
|
||||
header: string;
|
||||
options: QuestionOption[];
|
||||
multiSelect: boolean;
|
||||
}
|
||||
|
||||
function parseQuestions(input: unknown): Question[] | null {
|
||||
if (
|
||||
typeof input !== "object" ||
|
||||
input === null ||
|
||||
!("questions" in input) ||
|
||||
!Array.isArray((input as Record<string, unknown>).questions)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const raw = (input as Record<string, unknown>).questions as unknown[];
|
||||
const questions: Question[] = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item !== "object" || item === null) return null;
|
||||
const q = item as Record<string, unknown>;
|
||||
if (typeof q.question !== "string" || typeof q.header !== "string") return null;
|
||||
if (!Array.isArray(q.options)) return null;
|
||||
const options: QuestionOption[] = [];
|
||||
for (const opt of q.options as unknown[]) {
|
||||
if (typeof opt !== "object" || opt === null) return null;
|
||||
const o = opt as Record<string, unknown>;
|
||||
if (typeof o.label !== "string") return null;
|
||||
options.push({
|
||||
label: o.label,
|
||||
description: typeof o.description === "string" ? o.description : undefined,
|
||||
});
|
||||
}
|
||||
questions.push({
|
||||
question: q.question,
|
||||
header: q.header,
|
||||
options,
|
||||
multiSelect: q.multiSelect === true,
|
||||
});
|
||||
}
|
||||
return questions.length > 0 ? questions : null;
|
||||
}
|
||||
import {
|
||||
areQuestionsAnswered,
|
||||
buildQuestionFormAnswers,
|
||||
parseQuestionFormQuestions,
|
||||
questionShowsTextInput,
|
||||
resolveDismissLabel,
|
||||
shouldSubmitEmptyOnDismiss,
|
||||
type QuestionFormQuestion,
|
||||
type QuestionOption,
|
||||
} from "./question-form-card-core";
|
||||
|
||||
interface QuestionFormCardProps {
|
||||
permission: PendingPermission;
|
||||
@@ -70,6 +32,12 @@ interface QuestionFormCardProps {
|
||||
|
||||
const IS_WEB = isWeb;
|
||||
|
||||
function getQuestionInputPlaceholder(question: QuestionFormQuestion): string {
|
||||
return (
|
||||
question.placeholder ?? (question.options.length === 0 ? "Type your answer..." : "Other...")
|
||||
);
|
||||
}
|
||||
|
||||
interface QuestionOptionRowProps {
|
||||
qIndex: number;
|
||||
optIndex: number;
|
||||
@@ -137,6 +105,7 @@ function QuestionOptionRow({
|
||||
interface QuestionOtherInputProps {
|
||||
qIndex: number;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
isResponding: boolean;
|
||||
onChange: (qIndex: number, text: string) => void;
|
||||
onSubmit: () => void;
|
||||
@@ -145,6 +114,7 @@ interface QuestionOtherInputProps {
|
||||
function QuestionOtherInput({
|
||||
qIndex,
|
||||
value,
|
||||
placeholder,
|
||||
isResponding,
|
||||
onChange,
|
||||
onSubmit,
|
||||
@@ -179,7 +149,7 @@ function QuestionOtherInput({
|
||||
<TextInput
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={otherInputStyle}
|
||||
placeholder="Other..."
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
value={value}
|
||||
onChangeText={handleChange}
|
||||
@@ -193,7 +163,7 @@ function QuestionOtherInput({
|
||||
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const questions = parseQuestions(permission.request.input);
|
||||
const questions = parseQuestionFormQuestions(permission.request.input);
|
||||
|
||||
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
|
||||
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
|
||||
@@ -237,33 +207,17 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
}
|
||||
}, []);
|
||||
|
||||
const allAnswered =
|
||||
questions?.every((_, qIndex) => {
|
||||
const selected = selections[qIndex];
|
||||
const otherText = otherTexts[qIndex]?.trim();
|
||||
return (selected && selected.size > 0) || (otherText && otherText.length > 0);
|
||||
}) ?? false;
|
||||
const allAnswered = areQuestionsAnswered(questions, selections, otherTexts);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!questions || !allAnswered || isResponding) return;
|
||||
setRespondingAction("submit");
|
||||
const answers: Record<string, string> = {};
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i];
|
||||
const selected = selections[i];
|
||||
const otherText = otherTexts[i]?.trim();
|
||||
|
||||
if (otherText && otherText.length > 0) {
|
||||
answers[q.header] = otherText;
|
||||
} else if (selected && selected.size > 0) {
|
||||
const labels = Array.from(selected).map((idx) => q.options[idx].label);
|
||||
answers[q.header] = labels.join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
onRespond({
|
||||
behavior: "allow",
|
||||
updatedInput: { ...permission.request.input, answers },
|
||||
updatedInput: {
|
||||
...permission.request.input,
|
||||
answers: buildQuestionFormAnswers(questions, selections, otherTexts),
|
||||
},
|
||||
});
|
||||
}, [
|
||||
questions,
|
||||
@@ -276,12 +230,23 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
]);
|
||||
|
||||
const handleDeny = useCallback(() => {
|
||||
if (!questions) return;
|
||||
setRespondingAction("dismiss");
|
||||
if (shouldSubmitEmptyOnDismiss(questions)) {
|
||||
onRespond({
|
||||
behavior: "allow",
|
||||
updatedInput: {
|
||||
...permission.request.input,
|
||||
answers: buildQuestionFormAnswers(questions, selections, otherTexts),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
onRespond({
|
||||
behavior: "deny",
|
||||
message: "Dismissed by user",
|
||||
});
|
||||
}, [onRespond]);
|
||||
}, [questions, onRespond, otherTexts, permission.request.input, selections]);
|
||||
|
||||
const dismissButtonStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
@@ -349,11 +314,14 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
return null;
|
||||
}
|
||||
|
||||
const dismissLabel = resolveDismissLabel(questions);
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
{questions.map((q, qIndex) => {
|
||||
const selected = selections[qIndex] ?? new Set<number>();
|
||||
const otherText = otherTexts[qIndex] ?? "";
|
||||
const showTextInput = questionShowsTextInput(q);
|
||||
|
||||
return (
|
||||
<View key={q.question} style={styles.questionBlock}>
|
||||
@@ -361,27 +329,32 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
<Text style={questionTextStyle}>{q.question}</Text>
|
||||
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
<View style={styles.optionsWrap}>
|
||||
{q.options.map((opt, optIndex) => (
|
||||
<QuestionOptionRow
|
||||
key={opt.label}
|
||||
qIndex={qIndex}
|
||||
optIndex={optIndex}
|
||||
option={opt}
|
||||
isSelected={selected.has(optIndex)}
|
||||
multiSelect={q.multiSelect}
|
||||
isResponding={isResponding}
|
||||
onToggle={toggleOption}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<QuestionOtherInput
|
||||
qIndex={qIndex}
|
||||
value={otherText}
|
||||
isResponding={isResponding}
|
||||
onChange={setOtherText}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
{q.options.length > 0 ? (
|
||||
<View style={styles.optionsWrap}>
|
||||
{q.options.map((opt, optIndex) => (
|
||||
<QuestionOptionRow
|
||||
key={opt.label}
|
||||
qIndex={qIndex}
|
||||
optIndex={optIndex}
|
||||
option={opt}
|
||||
isSelected={selected.has(optIndex)}
|
||||
multiSelect={q.multiSelect}
|
||||
isResponding={isResponding}
|
||||
onToggle={toggleOption}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{showTextInput ? (
|
||||
<QuestionOtherInput
|
||||
qIndex={qIndex}
|
||||
value={otherText}
|
||||
placeholder={getQuestionInputPlaceholder(q)}
|
||||
isResponding={isResponding}
|
||||
onChange={setOtherText}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
@@ -393,7 +366,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
) : (
|
||||
<View style={styles.actionContent}>
|
||||
<X size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={dismissActionTextStyle}>Dismiss</Text>
|
||||
<Text style={dismissActionTextStyle}>{dismissLabel}</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { type RewindMode, useRewindCapabilities } from "./use-rewind-capabilities";
|
||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export type { RewindMode };
|
||||
|
||||
@@ -137,8 +137,8 @@ export const RewindMenu = memo(function RewindMenu({
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
padding: theme.spacing[1],
|
||||
paddingTop: theme.spacing[1],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "transparent",
|
||||
@@ -147,7 +147,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
triggerSlot: {
|
||||
alignSelf: "flex-start",
|
||||
alignSelf: "center",
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { RewindMode } from "./use-rewind-capabilities";
|
||||
import { useRewindComposerRestore } from "./composer-restore";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentCapabilityFlags } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export type RewindMode = "conversation" | "files" | "both";
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act } from "@testing-library/react";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { WorkspaceScriptPayload } from "@server/shared/messages";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import React from "react";
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@server/utils/branch-slug";
|
||||
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
import {
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
type Ref,
|
||||
} from "react";
|
||||
import { router, usePathname, type Href } from "expo-router";
|
||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { type GestureType } from "react-native-gesture-handler";
|
||||
@@ -99,7 +99,7 @@ import { Shortcut } from "@/components/ui/shortcut";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { type PrHint, useWorkspacePrHint } from "@/git/use-pr-status-query";
|
||||
import type { PrHint } from "@/git/use-pr-status-query";
|
||||
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
@@ -1346,14 +1346,7 @@ function WorkspaceRowInner({
|
||||
const _isCompact = useIsCompactFormFactor();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isTouchPlatform = platformIsNative;
|
||||
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
const prHint = useWorkspacePrHint({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspaceDirectory ?? "",
|
||||
enabled: workspace.projectKind === "git" && Boolean(workspaceDirectory),
|
||||
});
|
||||
const prHint = workspace.prHint;
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
menuController,
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SortableInlineList } from "./sortable-inline-list.web";
|
||||
|
||||
interface DndContextProps {
|
||||
onDragStart?: (event: { active: { id: string } }) => void;
|
||||
onDragCancel?: () => void;
|
||||
}
|
||||
|
||||
let latestDndContextProps: DndContextProps | null = null;
|
||||
|
||||
vi.mock("@dnd-kit/core", () => ({
|
||||
DndContext: ({ children, ...props }: React.PropsWithChildren<DndContextProps>) => {
|
||||
latestDndContextProps = props;
|
||||
return <div>{children}</div>;
|
||||
},
|
||||
closestCenter: vi.fn(),
|
||||
KeyboardSensor: vi.fn(),
|
||||
PointerSensor: vi.fn(),
|
||||
useSensor: vi.fn(() => ({})),
|
||||
useSensors: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("@dnd-kit/sortable", () => ({
|
||||
SortableContext: ({ children }: React.PropsWithChildren) => children,
|
||||
arrayMove: <T,>(items: T[], from: number, to: number) => {
|
||||
const next = [...items];
|
||||
const [item] = next.splice(from, 1);
|
||||
if (item !== undefined) {
|
||||
next.splice(to, 0, item);
|
||||
}
|
||||
return next;
|
||||
},
|
||||
horizontalListSortingStrategy: {},
|
||||
sortableKeyboardCoordinates: vi.fn(),
|
||||
useSortable: () => ({
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: vi.fn(),
|
||||
setActivatorNodeRef: vi.fn(),
|
||||
transform: null,
|
||||
transition: undefined,
|
||||
isDragging: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
latestDndContextProps = null;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const DATA: string[] = ["alpha", "beta"];
|
||||
|
||||
function keyExtractor(item: string): string {
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderItem({ item, isActive }: { item: string; isActive: boolean }) {
|
||||
return (
|
||||
<div data-active={String(isActive)} data-testid={`item-${item}`}>
|
||||
{item}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderList(): void {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<SortableInlineList
|
||||
data={DATA}
|
||||
keyExtractor={keyExtractor}
|
||||
onDragEnd={vi.fn()}
|
||||
renderItem={renderItem}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getItemActiveState(item: string): string | null {
|
||||
return (
|
||||
container?.querySelector(`[data-testid="item-${item}"]`)?.getAttribute("data-active") ?? null
|
||||
);
|
||||
}
|
||||
|
||||
describe("SortableInlineList web", () => {
|
||||
it("clears active drag state when a drag is cancelled", () => {
|
||||
renderList();
|
||||
|
||||
act(() => {
|
||||
latestDndContextProps?.onDragStart?.({ active: { id: "alpha" } });
|
||||
});
|
||||
expect(getItemActiveState("alpha")).toBe("true");
|
||||
|
||||
act(() => {
|
||||
latestDndContextProps?.onDragCancel?.();
|
||||
});
|
||||
expect(getItemActiveState("alpha")).toBe("false");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -7,18 +7,16 @@ import {
|
||||
type Modifier,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
horizontalListSortingStrategy,
|
||||
useSortable,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { DraggableRenderItemInfo } from "./draggable-list.types";
|
||||
import { useDragReorderState } from "./drag-reorder";
|
||||
|
||||
const restrictToHorizontalAxis: Modifier = ({ transform }) => ({
|
||||
...transform,
|
||||
@@ -138,9 +136,19 @@ export function SortableInlineList<T>({
|
||||
activeId?: string | null;
|
||||
getItemData?: (item: T, index: number) => Record<string, unknown>;
|
||||
}): ReactElement {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [dragItems, setDragItems] = useState<T[] | null>(null);
|
||||
const items = externalDndContext ? data : (dragItems ?? data);
|
||||
const {
|
||||
activeId: internalActiveId,
|
||||
items: managedItems,
|
||||
handlers,
|
||||
} = useDragReorderState({
|
||||
data,
|
||||
keyExtractor,
|
||||
onDragEnd,
|
||||
onDragBegin,
|
||||
disabled,
|
||||
});
|
||||
const items = externalDndContext ? data : managedItems;
|
||||
const activeId = externalDndContext ? externalActiveId : internalActiveId;
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -153,46 +161,6 @@ export function SortableInlineList<T>({
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
setDragItems(data);
|
||||
setActiveId(String(event.active.id));
|
||||
onDragBegin?.();
|
||||
},
|
||||
[data, disabled, onDragBegin],
|
||||
);
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
setActiveId(null);
|
||||
setDragItems(null);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
clearDragState();
|
||||
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id);
|
||||
const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id);
|
||||
|
||||
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
onDragEnd?.(newItems);
|
||||
}
|
||||
}
|
||||
},
|
||||
[clearDragState, disabled, items, keyExtractor, onDragEnd],
|
||||
);
|
||||
|
||||
const ids = useMemo(
|
||||
() => items.map((item, index) => keyExtractor(item, index)),
|
||||
[items, keyExtractor],
|
||||
@@ -209,7 +177,7 @@ export function SortableInlineList<T>({
|
||||
item={item}
|
||||
index={index}
|
||||
renderItem={renderItem}
|
||||
activeId={externalDndContext ? externalActiveId : activeId}
|
||||
activeId={activeId}
|
||||
useDragHandle={useDragHandle}
|
||||
disabled={disabled}
|
||||
itemData={getItemData?.(item, index)}
|
||||
@@ -229,9 +197,9 @@ export function SortableInlineList<T>({
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={DND_MODIFIERS}
|
||||
onDragStart={handleDragStart}
|
||||
onDragCancel={clearDragState}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={handlers.onDragStart}
|
||||
onDragCancel={handlers.onDragCancel}
|
||||
onDragEnd={handlers.onDragEnd}
|
||||
>
|
||||
{renderedItems}
|
||||
</DndContext>
|
||||
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
} from "react-native";
|
||||
import { WebView, type WebViewMessageEvent } from "react-native-webview";
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
import type { TerminalState } from "@server/shared/messages";
|
||||
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
|
||||
import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime";
|
||||
import type {
|
||||
TerminalLocalFileLinkSource,
|
||||
|
||||
@@ -17,8 +17,8 @@ import type { DOMProps } from "expo/dom";
|
||||
import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
import type { TerminalState } from "@server/shared/messages";
|
||||
import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
|
||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||
import {
|
||||
TerminalEmulatorRuntime,
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
isTerminalFileDrag,
|
||||
prepareDroppedPathsForTerminal,
|
||||
} from "../terminal/drop/terminal-file-drop";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
|
||||
export interface TerminalEmulatorHandle {
|
||||
writeOutput: (data: TerminalOutputData) => void;
|
||||
@@ -833,13 +834,14 @@ export default function TerminalEmulator({
|
||||
event.stopPropagation();
|
||||
clearTerminalDropActive();
|
||||
|
||||
const paths = extractTerminalDropPaths(event.dataTransfer);
|
||||
const bridge = getDesktopHost();
|
||||
const paths = extractTerminalDropPaths(event.dataTransfer, bridge);
|
||||
if (paths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeRef.current?.focus();
|
||||
mountCallbacksRef.current.onInput?.(prepareDroppedPathsForTerminal(paths));
|
||||
mountCallbacksRef.current.onInput?.(prepareDroppedPathsForTerminal(paths, bridge));
|
||||
};
|
||||
|
||||
root.addEventListener("dragenter", handleDragEnter, { capture: true });
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user