mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcb3157e75 | ||
|
|
de162b98d4 | ||
|
|
0a960f9ae0 | ||
|
|
7c4f8580e9 | ||
|
|
33674e0b10 | ||
|
|
0ecc51bd99 | ||
|
|
526e6070f8 | ||
|
|
40d6a9127a |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: release-beta
|
||||
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are silent release candidates — no changelog, no website move, npm only on the beta dist-tag.
|
||||
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are silent release candidates — no changelog, no website move.
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
@@ -8,4 +8,4 @@ user-invocable: true
|
||||
|
||||
Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc.
|
||||
|
||||
Key rules the doc enforces — betas don't touch `CHANGELOG.md`, don't draft release notes, and publish npm only with the explicit `beta` dist-tag.
|
||||
Key rule the doc enforces — betas don't touch `CHANGELOG.md`. Don't draft release notes.
|
||||
|
||||
@@ -64,7 +64,6 @@ CLAUDE.local.md
|
||||
|
||||
.debug.conversations/
|
||||
.debug/
|
||||
.dev/
|
||||
.paseo/
|
||||
.wrangler/
|
||||
**/.wrangler/
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"options": {
|
||||
"typeAware": false
|
||||
},
|
||||
"ignorePatterns": [".dev/**"],
|
||||
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
|
||||
-126
@@ -1,131 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.91 - 2026-06-08
|
||||
|
||||
### Added
|
||||
|
||||
- **Open multiple desktop windows** ([#1355](https://github.com/getpaseo/paseo/pull/1355) by [@arieel-ost](https://github.com/arieel-ost))
|
||||
- **Open browser pop-ups and links inside workspace tabs** ([#1375](https://github.com/getpaseo/paseo/pull/1375))
|
||||
- **Use the command center from mobile**
|
||||
- **Add OMP as a provider** ([#1388](https://github.com/getpaseo/paseo/pull/1388))
|
||||
|
||||
### Improved
|
||||
|
||||
- New workspaces remember your last provider, mode, and thinking choices
|
||||
- Git controls now default ready branches to pull requests and hide unavailable pull or push actions
|
||||
- Desktop-managed hosts recover more reliably after stale daemon state
|
||||
- Daemon status now explains authentication failures
|
||||
- Project search skips Python virtual environments ([#1356](https://github.com/getpaseo/paseo/pull/1356))
|
||||
- Config files can include `$schema` for editor help
|
||||
- Claude MCP servers preserve always-load tool settings ([#1333](https://github.com/getpaseo/paseo/pull/1333) by [@nodomain](https://github.com/nodomain))
|
||||
- Claude profiles keep their configured models ([#1311](https://github.com/getpaseo/paseo/pull/1311) by [@ilteoood](https://github.com/ilteoood))
|
||||
- Provider loading can wait longer on slow machines ([#1346](https://github.com/getpaseo/paseo/pull/1346) by [@nodomain](https://github.com/nodomain))
|
||||
- The Kimi catalog entry now points to Kimi Code CLI ([#1403](https://github.com/getpaseo/paseo/pull/1403) by [@wbxl2000](https://github.com/wbxl2000))
|
||||
- ACP provider catalog entries are updated for Auggie, Claude Agent, Cline, Codebuddy Code, DimCode, Factory Droid, fast-agent, Gemini, GitHub Copilot, and Nova
|
||||
- Local dictation crash reports show more useful details ([#1379](https://github.com/getpaseo/paseo/pull/1379))
|
||||
- Daemon logs show why managed workers exit
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pi compaction slash commands run correctly ([#1338](https://github.com/getpaseo/paseo/pull/1338) by [@chyendongnhanh338](https://github.com/chyendongnhanh338))
|
||||
- Auto-archiving still works after a merged PR branch is deleted ([#1378](https://github.com/getpaseo/paseo/pull/1378))
|
||||
- Worktrees can check out existing branch refs correctly ([#1358](https://github.com/getpaseo/paseo/pull/1358) by [@dixonl90](https://github.com/dixonl90))
|
||||
- File downloads work when daemon password protection is enabled ([#1351](https://github.com/getpaseo/paseo/pull/1351) by [@nodomain](https://github.com/nodomain))
|
||||
- iOS markdown links are tappable again ([#1334](https://github.com/getpaseo/paseo/pull/1334) by [@kaspesi](https://github.com/kaspesi))
|
||||
- iOS markdown images render correctly
|
||||
- Windows workspaces load their providers correctly ([#1329](https://github.com/getpaseo/paseo/pull/1329))
|
||||
- Removing a localhost host stops its local daemon ([#1297](https://github.com/getpaseo/paseo/pull/1297) by [@mcowger](https://github.com/mcowger))
|
||||
- Provider settings sheets stack correctly
|
||||
- The new workspace screen no longer opens behind the mobile sidebar
|
||||
- Global agent listing works again ([#1420](https://github.com/getpaseo/paseo/pull/1420))
|
||||
- OpenCode compaction summaries stay out of chat
|
||||
- OpenCode agents sharing a workspace keep their own Paseo tools
|
||||
|
||||
## 0.1.90 - 2026-06-04
|
||||
|
||||
### Added
|
||||
|
||||
- **Group the sidebar by status so workspaces waiting on you, ready to review, working, and done are visible at a glance** ([#1317](https://github.com/getpaseo/paseo/pull/1317))
|
||||
- **Start a new workspace from the global sidebar button without choosing a project first** ([#1324](https://github.com/getpaseo/paseo/pull/1324))
|
||||
- **Open the active file directly in your editor, file manager, or GitHub instead of only opening the workspace root** ([#1285](https://github.com/getpaseo/paseo/pull/1285) by [@aaronzhongg](https://github.com/aaronzhongg))
|
||||
- **Automatically archive clean PR workspaces after the PR is merged from host settings** ([#1313](https://github.com/getpaseo/paseo/pull/1313))
|
||||
- **Desktop-managed Paseo skills stay current after installing a newer desktop build** ([#1309](https://github.com/getpaseo/paseo/pull/1309))
|
||||
- **Dart files and Dart code blocks are now syntax-highlighted** ([#1326](https://github.com/getpaseo/paseo/pull/1326))
|
||||
|
||||
### Improved
|
||||
|
||||
- Sidebar workspaces can be marked as read when they are ready to review or failed ([#1317](https://github.com/getpaseo/paseo/pull/1317))
|
||||
- Child agents keep unattended permissions when delegated across providers ([#1315](https://github.com/getpaseo/paseo/pull/1315))
|
||||
- Scheduled agents open with the real prompt and title instead of looking empty ([#1316](https://github.com/getpaseo/paseo/pull/1316))
|
||||
- Git controls prioritize the action that gets a ready branch shipped ([#1316](https://github.com/getpaseo/paseo/pull/1316))
|
||||
- Multiple agent questions are shown one at a time
|
||||
- OpenCode questions with free-write answers show the typed response in Paseo
|
||||
- Delegated agent activity is visible on the parent workspace
|
||||
- Sessions are ordered by latest activity
|
||||
- ACP provider catalog entries are updated for Claude Agent, Cline, Codebuddy Code, Factory Droid, and Qoder
|
||||
|
||||
### Fixed
|
||||
|
||||
- Timeline catch-up no longer leaves older messages unloaded
|
||||
- Markdown code in file previews renders correctly
|
||||
- Long dictation retries no longer stall new audio
|
||||
- Settings host picker navigation works from host settings pages
|
||||
- Diff gutter rows stay aligned with changed code
|
||||
- Mobile sidebar gestures stay responsive under load
|
||||
- Compact sheets keep their footer and bottom spacing visible
|
||||
|
||||
## 0.1.89 - 2026-06-02
|
||||
|
||||
### Added
|
||||
|
||||
- **Open workspace services through public service proxy links** ([#1280](https://github.com/getpaseo/paseo/pull/1280) by [@mcowger](https://github.com/mcowger))
|
||||
- **Choose where new worktrees are created** ([#1230](https://github.com/getpaseo/paseo/pull/1230) by [@mcowger](https://github.com/mcowger))
|
||||
- **Desktop windows reopen at the same size and position** ([#1224](https://github.com/getpaseo/paseo/pull/1224) by [@everton-dgn](https://github.com/everton-dgn))
|
||||
- **Delegated agents can run independently and send recurring heartbeat updates**
|
||||
|
||||
### Improved
|
||||
|
||||
- Composer controls fit better in narrow panes
|
||||
- Fork pull request badges stay visible in worktrees
|
||||
- Cline in the ACP catalog is updated to v3
|
||||
|
||||
### Fixed
|
||||
|
||||
- Archiving a worktree finishes even if teardown hits an error ([#1260](https://github.com/getpaseo/paseo/pull/1260) by [@mcowger](https://github.com/mcowger))
|
||||
- iOS chat messages render bold, italics, strikethrough, and line breaks correctly ([#1254](https://github.com/getpaseo/paseo/pull/1254) by [@outofrange-consulting](https://github.com/outofrange-consulting))
|
||||
- Right-edge split pane resizing no longer clips ([#1261](https://github.com/getpaseo/paseo/pull/1261) by [@everton-dgn](https://github.com/everton-dgn))
|
||||
- Pi extension command output no longer hangs
|
||||
- Delegated agents no longer appear in workspace alert counts
|
||||
|
||||
## 0.1.88 - 2026-06-01
|
||||
|
||||
### Added
|
||||
|
||||
- **Choose an app theme from the new Appearance settings**
|
||||
- **Set a custom interface font**
|
||||
- **Set a custom code font**
|
||||
- **Adjust the interface text size**
|
||||
- **Adjust the code text size**
|
||||
- **Choose a syntax highlighting theme**
|
||||
- **Keep cron schedules aligned to a chosen time zone** ([#1232](https://github.com/getpaseo/paseo/pull/1232) by [@damselem](https://github.com/damselem))
|
||||
|
||||
### Improved
|
||||
|
||||
- Settings now has a flatter sidebar with a host picker
|
||||
- Workspace tab switching is faster
|
||||
- Compact composers now show context usage as a percentage
|
||||
- Agent terminals opened in workspace subdirectories now appear with the rest of the workspace terminals
|
||||
- macOS displays can idle normally while the desktop app is open ([#1242](https://github.com/getpaseo/paseo/pull/1242) by [@fireblue](https://github.com/fireblue))
|
||||
- Large generated diffs now show a clear too-large placeholder instead of trying to render the whole file
|
||||
|
||||
### Fixed
|
||||
|
||||
- Chat history catches up correctly around long-running tool updates
|
||||
- Terminal panes keep the right size after splitting or resizing panes
|
||||
- Restored terminal snapshots reflow correctly after the pane size changes
|
||||
- Workspace scripts menus keep the right size after launching a service
|
||||
- iOS chat messages no longer hide inline links, URLs, or linked file paths ([#1257](https://github.com/getpaseo/paseo/pull/1257) by [@outofrange-consulting](https://github.com/outofrange-consulting))
|
||||
|
||||
## 0.1.87 - 2026-05-30
|
||||
|
||||
### Added
|
||||
|
||||
@@ -36,7 +36,6 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
@@ -49,9 +48,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
npm run dev # Start the dev daemon
|
||||
npm run dev:app # Start Expo against the dev daemon
|
||||
npm run dev:desktop # Start Electron desktop dev
|
||||
npm run dev # Start daemon + Expo in Tmux
|
||||
npm run cli -- ls -a -g # List all agents
|
||||
npm run cli -- daemon status # Check daemon status
|
||||
npm run typecheck # Always run after changes
|
||||
@@ -60,8 +57,6 @@ npm run format # Auto-format with Biome
|
||||
npm run format:check # Check formatting without writing
|
||||
```
|
||||
|
||||
Repo dev commands use checkout-local state by default. In this checkout, `PASEO_HOME` resolves to `.dev/paseo-home`, and `npm run cli -- ...` targets that same dev home automatically. The packaged desktop app and production-style daemon keep using `~/.paseo` on port `6767`.
|
||||
|
||||
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
|
||||
|
||||
## Critical rules
|
||||
|
||||
+17
-17
@@ -14,14 +14,14 @@ Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `
|
||||
|
||||
## Relationships
|
||||
|
||||
Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Agent-scoped creation is always asynchronous. By default, the daemon stamps the created agent with a label `paseo.parent-agent-id` pointing back at the agent that created it. The client surfaces that as `agent.parentAgentId`.
|
||||
Agents can launch other agents via the `create_agent` MCP tool. When they do, the daemon stamps the new agent with a label `paseo.parent-agent-id` pointing back at the caller (`packages/server/src/server/agent/mcp-server.ts:804`). The client surfaces that as `agent.parentAgentId`.
|
||||
|
||||
Agent-scoped `create_agent` accepts `detached: true` for agents that should stand on their own. The daemon still uses the creating agent for cwd/config inheritance, but does not write `paseo.parent-agent-id`.
|
||||
There is exactly one relationship type today: `parentAgentId`. The daemon does not distinguish between:
|
||||
|
||||
- **Subagents** — created with `detached: false` or omitted. They exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
|
||||
- **Detached agents** — created with `detached: true`. They take over as sibling/root agents (e.g. handoffs, fire-and-forget delegations), do not appear in the creating agent's subagent track, and are not archived with it.
|
||||
- **Subagents** — children that exist as part of the parent's work (e.g. orchestration tasks the parent delegates and waits on)
|
||||
- **Detached agents** — children launched to take over from the parent (e.g. handoffs, fire-and-forget delegations)
|
||||
|
||||
`notifyOnFinish` defaults to `true` for agent-scoped creation because most subagents are delegated work the creating agent needs to hear back from. Set it to `false` only for truly fire-and-forget agents.
|
||||
Both look the same in storage. This is an accepted limitation — see [Limitations](#limitations).
|
||||
|
||||
## Archive
|
||||
|
||||
@@ -54,12 +54,6 @@ Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-onl
|
||||
|
||||
The asymmetry is intentional: a subagent's home is the parent's track, not the tab. Tabs are ephemeral viewing slots; the track is the persistent record of the parent's children.
|
||||
|
||||
## Workspace activity
|
||||
|
||||
Agent lifecycle status stays literal: a parent agent is `idle` when its own turn is idle, even if a child is running.
|
||||
|
||||
Workspace status is an aggregate activity signal. Root agents contribute their normal state bucket to their own workspace. Running subagents contribute `running` to their root parent's workspace, not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket.
|
||||
|
||||
## The subagents track
|
||||
|
||||
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`). Membership rule (`packages/app/src/subagents/select.ts`):
|
||||
@@ -83,6 +77,12 @@ We considered universal decoupling (no tab close ever archives, archive is alway
|
||||
|
||||
## Limitations
|
||||
|
||||
### Detached agents are cascade-archived
|
||||
|
||||
The daemon can't tell a "subagent" apart from a "detached agent" — both carry `paseo.parent-agent-id`. So when you archive an agent that previously launched a detached child (e.g. via `/paseo-handoff`), cascade will archive the detached child too, even though semantically it should outlive the originator.
|
||||
|
||||
Until a richer relation model lands (e.g. a `relation: "subagent" | "detached"` field on creation, or a separate channel for handoff launches), this trade-off stands. Workaround: don't archive an agent whose work was handed off, or unarchive the detached child afterward.
|
||||
|
||||
### Subagent accumulation under long-lived parents
|
||||
|
||||
A parent that spawns many subagents will see the track grow. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem.
|
||||
@@ -99,11 +99,11 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
||||
|
||||
Each agent is a single JSON file. Fields relevant to this doc:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------------- | ------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by agent-scoped `create_agent` unless `detached: true` |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------------- | ------------- | ------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` MCP tool |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
|
||||
See [`docs/data-model.md`](./data-model.md) for the full agent record.
|
||||
|
||||
@@ -132,12 +132,6 @@ Electron wrapper for macOS, Linux, and Windows.
|
||||
- Native file access for workspace integration
|
||||
- Same WebSocket client as mobile app
|
||||
|
||||
**Multi-window (hybrid land-on model).** `createWindow()` in `main.ts` is reusable: `⌘⇧N`/File→New Window, relaunching the app (`second-instance`), and the sidebar "Open in new window" action each open a fresh `BrowserWindow`. Every window shows the full sidebar — there is no per-window project ownership or filtering. "Land on a project" is delivered by a per-`webContents` `PendingOpenProjectStore`: each window pulls its own pending project path on mount (`paseo:get-pending-open-project`) and runs the normal open-project flow, identical to a CLI `paseo <path>` launch.
|
||||
|
||||
> **Window-state v1 limitation:** only the _first_ window of a session restores and persists saved geometry (size/position/maximized). Windows opened via ⌘⇧N / second-instance / "Open in new window" open at the default size, OS-cascaded, and do not persist — this avoids every window stacking on the same restored bounds and fighting over the single window-state store. Lifting this needs per-window state keys.
|
||||
>
|
||||
> **In-app browser panes are not yet per-window.** The active-browser id (`features/browser-webviews.ts`) and the webview registration queue (`pendingBrowserWebviewIds` in `main.ts`) are process-global. With browser panes open in two windows, a menu Reload can target the other window's webview, and near-simultaneous webview attach across windows can register under the wrong browser id. Multi-window v1 ships windows; making the browser-webview subsystem window-scoped is a follow-up.
|
||||
|
||||
### `packages/website` — Marketing site
|
||||
|
||||
TanStack Router + Cloudflare Workers. Serves paseo.sh.
|
||||
@@ -189,8 +183,6 @@ Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStream
|
||||
- 1-byte slot: terminal slot id
|
||||
- variable payload: bytes for output/input, JSON-encoded `{ rows, cols }` for resize, terminal snapshot for snapshot
|
||||
|
||||
Terminal PTY size is last-interacting-client-wins. A client claims the PTY size only when its terminal viewport genuinely changes size or the user focuses/taps the terminal. Passive rendering work — attaching, restoring visibility, font settling, renderer refits, or just looking at a visible terminal — must not send a resize frame. The server does not broadcast resize ownership; the resized PTY redraws through normal output, and every attached client renders that output in its own local viewport.
|
||||
|
||||
There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams.
|
||||
|
||||
### Compatibility rules
|
||||
@@ -238,7 +230,7 @@ initializing → idle ⇄ running
|
||||
|
||||
Each provider implements the `AgentClient` interface in `agent/agent-sdk-types.ts`. Provider implementations live in `agent/providers/`.
|
||||
|
||||
The built-in, user-facing providers are Claude Code, Codex, Copilot, OpenCode, Pi, and OMP. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
|
||||
The built-in, user-facing providers are Claude Code, Codex, Copilot, OpenCode, and Pi. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
|
||||
|
||||
| Provider | Wraps | Session format |
|
||||
| ------------------ | ------------------------------------ | -------------------------------------------------- |
|
||||
|
||||
@@ -35,7 +35,7 @@ Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`)
|
||||
|
||||
## Extending a built-in provider
|
||||
|
||||
Use `extends` to create a new provider entry that inherits from a built-in provider (claude, codex, copilot, opencode, pi, omp). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions.
|
||||
Use `extends` to create a new provider entry that inherits from a built-in provider (claude, codex, copilot, opencode, pi). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -347,41 +347,6 @@ Override the command used to launch any provider with the `command` field. This
|
||||
|
||||
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
|
||||
|
||||
### Pi-compatible forks with their own session directory
|
||||
|
||||
OMP already ships as a built-in provider option. It is disabled by default; enable it with:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"omp": { "enabled": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-pi-fork": {
|
||||
"extends": "pi",
|
||||
"label": "My Pi Fork",
|
||||
"command": ["my-pi-fork"],
|
||||
"params": {
|
||||
"sessionDir": "~/.my-pi-fork/sessions"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The session directory is used only for importing sessions that were started outside Paseo. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
|
||||
|
||||
---
|
||||
|
||||
## Disabling a provider
|
||||
@@ -399,7 +364,7 @@ Set `enabled: false` to hide a provider from the provider list. The provider wil
|
||||
}
|
||||
```
|
||||
|
||||
This works for both built-in and custom providers. To re-enable, set `enabled: true` or remove the `enabled` field entirely. Most providers are enabled by default; OMP is intentionally disabled by default and requires `enabled: true`.
|
||||
This works for both built-in and custom providers. To re-enable, set `enabled: true` or remove the `enabled` field entirely (providers are enabled by default).
|
||||
|
||||
---
|
||||
|
||||
@@ -612,7 +577,7 @@ Each entry in the `models` array:
|
||||
|
||||
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
||||
|
||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
|
||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. `agents.providers.claude.models` is still supported and is additive for the built-in Claude provider; duplicate IDs are de-duplicated.
|
||||
|
||||
### Gotcha: `extends: "claude"` with third-party endpoints
|
||||
|
||||
@@ -639,7 +604,7 @@ Use `disallowedTools` to disable unsupported tools:
|
||||
|
||||
### Valid `extends` values
|
||||
|
||||
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`, `omp`
|
||||
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`
|
||||
|
||||
Special value: `acp` — creates a generic ACP provider (requires `command`)
|
||||
|
||||
|
||||
+3
-6
@@ -30,7 +30,7 @@ $PASEO_HOME/
|
||||
└── push-tokens.json # Expo push notification tokens
|
||||
```
|
||||
|
||||
The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` by stripping the filesystem root and replacing path separators with `-` (Windows drive letters become a `C-` style prefix). Persistent server stores write atomically by writing a temp file in the target directory and then renaming it into place.
|
||||
The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` by stripping the filesystem root and replacing path separators with `-` (Windows drive letters become a `C-` style prefix). Atomic writes (temp file + rename): agent records, chat, project/workspace registries, push tokens. Non-atomic (plain `writeFile`): `config.json`, `schedules/*.json`, `loops/loops.json`, `server-id`, `daemon-keypair.json`.
|
||||
|
||||
---
|
||||
|
||||
@@ -147,9 +147,6 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
app: {
|
||||
baseUrl: string
|
||||
},
|
||||
worktrees?: {
|
||||
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
|
||||
},
|
||||
providers: {
|
||||
openai: { apiKey: string },
|
||||
local: { modelsDir: string }
|
||||
@@ -187,7 +184,7 @@ Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-
|
||||
|
||||
**Path:** `$PASEO_HOME/schedules/{id}.json`
|
||||
|
||||
One file per schedule. ID is 8 hex characters.
|
||||
One file per schedule. ID is 8 hex characters. Writes are direct (not atomic).
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------- | ------------------------------------- | -------------------------------- |
|
||||
@@ -209,7 +206,7 @@ One file per schedule. ID is 8 hex characters.
|
||||
### Nested: ScheduleCadence (discriminated union on `type`)
|
||||
|
||||
- `{ type: "every", everyMs: number }` — interval in milliseconds
|
||||
- `{ type: "cron", expression: string, timezone?: string }` — cron expression; absent `timezone` means UTC, present `timezone` is an IANA time zone used for local wall-clock recurrence
|
||||
- `{ type: "cron", expression: string }` — cron expression
|
||||
|
||||
### Nested: ScheduleTarget (discriminated union on `type`)
|
||||
|
||||
|
||||
@@ -94,8 +94,6 @@ Five primitives. The pick is determined by option count, the need to search, and
|
||||
|
||||
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
|
||||
|
||||
`<AdaptiveModalSheet>` owns compact bottom safe-area padding inside the sheet so the sheet background still reaches the screen bottom. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
|
||||
|
||||
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
|
||||
|
||||
Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value is `AdaptiveModalSheet`. "Are you sure?" is `confirmDialog`.
|
||||
|
||||
+12
-113
@@ -8,27 +8,18 @@
|
||||
## Running the dev server
|
||||
|
||||
```bash
|
||||
npm run dev:server
|
||||
npm run dev:app
|
||||
npm run dev:desktop
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Root checkout dev is intentionally split across terminals:
|
||||
|
||||
- `npm run dev:server` runs the daemon on `127.0.0.1:6768`.
|
||||
- `npm run dev:app` runs Expo on `http://localhost:8081` and connects to the dev daemon.
|
||||
- `npm run dev:desktop` runs its own Electron-flavored Expo server on the first free port from `8082` through `8089`. It never claims port `8081`.
|
||||
|
||||
`npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state.
|
||||
`scripts/dev.sh` runs the daemon and Expo together via `concurrently`, fronted by [`portless`](https://www.npmjs.com/package/portless) so each service is reachable at a stable name like `https://daemon.localhost` / `https://app.localhost` instead of a fixed port. The underlying TCP ports are ephemeral — never hardcode them. (Windows uses `scripts/dev.ps1`, which still binds the daemon to `localhost:6767` directly.)
|
||||
|
||||
### PASEO_HOME
|
||||
|
||||
`PASEO_HOME` is the directory that holds runtime state (agents, worktrees, workspace config, sockets, daemon log). Resolution rules:
|
||||
`PASEO_HOME` is the directory that holds runtime state (agents, sockets, daemon log). Resolution rules:
|
||||
|
||||
- The **server itself** (e.g. when launched by the desktop app or `npm run start`) defaults to `~/.paseo` (see `packages/server/src/server/paseo-home.ts`).
|
||||
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
|
||||
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
|
||||
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
|
||||
- **`npm run dev` from a git worktree** derives a stable home like `~/.paseo-<worktree-name>` and, on first run, seeds it from `~/.paseo` by copying agent/project JSON metadata and `config.json`. Checkout/worktree directories are not copied.
|
||||
- **`npm run dev` from the main checkout** (not a worktree) uses a fresh `mktemp` directory under `$TMPDIR` and removes it on exit. Set `PASEO_HOME` explicitly to keep state across runs.
|
||||
|
||||
Override knobs:
|
||||
|
||||
@@ -41,83 +32,16 @@ PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived wor
|
||||
### Daemon endpoints
|
||||
|
||||
- Stable daemon launched by the desktop app: `localhost:6767`.
|
||||
- Root checkout dev daemon: `localhost:6768`.
|
||||
- Root checkout Expo: `http://localhost:8081`.
|
||||
- Root checkout desktop dev Expo: first free port from `8082` through `8089`.
|
||||
- `npm run dev` (macOS/Linux): portless URLs only — read them from the `dev.sh` banner or `portless get daemon` / `portless get app`.
|
||||
- `npm run dev` (Windows): `localhost:6767` for the daemon.
|
||||
|
||||
In Paseo-managed worktree services, use the injected service environment rather than hardcoded root checkout ports.
|
||||
In any worktree-style or portless setup, never assume default ports.
|
||||
|
||||
### Desktop renderer profiling
|
||||
|
||||
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on
|
||||
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
|
||||
It launches its own Electron-flavored Expo server and passes that URL to Electron.
|
||||
Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
|
||||
|
||||
### React render profiling
|
||||
|
||||
The app has a gated React render profiler in
|
||||
`packages/app/src/utils/render-profiler.tsx`. Wrap the component boundary you want
|
||||
to measure with `RenderProfile`, then open the app with `?renderProfile=1`. When
|
||||
the query param is absent, `RenderProfile` returns children directly and records
|
||||
nothing.
|
||||
|
||||
Captured samples are exposed on `globalThis.__PASEO_RENDER_PROFILE__`. Call
|
||||
`globalThis.__PASEO_RESET_RENDER_PROFILE__?.()` after warm-up and before the
|
||||
interaction you want to measure. If a memo comparator or subscription boundary
|
||||
needs explanation, call `recordRenderProfileReasons(id, reasons)` while profiling;
|
||||
reason counts are exposed on `globalThis.__PASEO_RENDER_PROFILE_REASONS__`.
|
||||
|
||||
Use this workflow for any render investigation:
|
||||
|
||||
1. Add stable `RenderProfile` boundaries around the suspected root and expensive
|
||||
children. Keep IDs specific enough to compare before and after.
|
||||
2. Reproduce against real app state, not toy fixtures, whenever practical.
|
||||
3. Record an idle baseline first. If idle is noisy, fix or account for that
|
||||
before optimizing the interaction.
|
||||
4. Warm up the route, reset profiler samples, run the exact interaction, then
|
||||
compare `actualDuration`, render counts, and per-commit samples.
|
||||
5. When a memo boundary still renders, record reasons before changing code. Do
|
||||
not guess from object identity alone.
|
||||
6. Keep changes that move the measured profile. Remove probes or memo wrappers
|
||||
that do not move the number.
|
||||
|
||||
What this caught during the workspace tab investigation:
|
||||
|
||||
- A large apparent workspace cost was real interaction work, not daemon noise;
|
||||
the idle baseline stayed near zero.
|
||||
- The expensive stream rerender was mostly prop identity churn from pane context
|
||||
callbacks and capability objects, not new stream data.
|
||||
- Stabilizing provider actions at the pane boundary helped because every mounted
|
||||
panel consumes that context.
|
||||
- Comparing value-shaped capability flags beat preserving object identity through
|
||||
unrelated stores.
|
||||
- Some plausible fixes did not pay off: memoizing the tab row and composer draft
|
||||
object barely moved the profile, so they were removed.
|
||||
|
||||
Existing scenario script: workspace agent/terminal tab switching. Start Expo on
|
||||
web, keep a daemon available, then run:
|
||||
|
||||
```bash
|
||||
PASEO_PROFILE_SERVER_ID=<server-id> \
|
||||
PASEO_PROFILE_WORKSPACE_ID=<workspace-path> \
|
||||
PASEO_PROFILE_AGENT_ID=<agent-id> \
|
||||
npm run profile:workspace-tabs --workspace=@getpaseo/app
|
||||
```
|
||||
|
||||
This script opens the app with `?renderProfile=1`, creates a temporary terminal
|
||||
tab, switches between a real agent and that terminal, prints aggregated React
|
||||
Profiler timings, then removes the temporary terminal. It is an example of the
|
||||
workflow above, not the only way to use the profiler. Useful knobs:
|
||||
|
||||
```bash
|
||||
PASEO_PROFILE_APP_URL=http://localhost:19010 # Expo web URL
|
||||
PASEO_PROFILE_SWITCH_COUNT=1 # number of agent/terminal switch pairs
|
||||
PASEO_PROFILE_SWITCH_WAIT_MS=250 # delay after each click
|
||||
PASEO_PROFILE_IDLE_WAIT_MS=3000 # idle baseline before switching
|
||||
PASEO_PROFILE_DUMP_COMMITS=1 # include per-commit profiler samples
|
||||
```
|
||||
Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
|
||||
|
||||
### Desktop macOS compositor watchdog
|
||||
|
||||
@@ -135,12 +59,6 @@ GPU process so Chromium rebuilds the display link. The probe is skipped while
|
||||
the screen is locked or the window is hidden or minimized, since a window
|
||||
legitimately stops producing frames then.
|
||||
|
||||
The watchdog deliberately leaves background throttling **enabled**. Calling
|
||||
`webContents.setBackgroundThrottling(false)` would keep the compositor producing
|
||||
frames non-stop, pinning ProMotion displays at 120Hz forever and draining the
|
||||
battery while the app is idle — so do not re-add it. The probe's visibility
|
||||
guards already prevent throttling from causing a false stall.
|
||||
|
||||
### Daemon logs
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
||||
@@ -170,14 +88,12 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
|
||||
|
||||
| Variable | Value |
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PASEO_SERVICE_<NAME>_URL` | Proxied URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
|
||||
| `PASEO_SERVICE_<NAME>_URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
|
||||
| `PASEO_SERVICE_<NAME>_PORT` | Raw ephemeral port for a declared peer service. Use only as a bypass escape hatch; it can go stale if that peer restarts. |
|
||||
| `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
|
||||
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
|
||||
| `HOST` | Bind host for the service process. |
|
||||
|
||||
Service proxy hostnames use the double-dash shape: `web--feature-auth--project.localhost` or, on the default branch, `web--project.localhost`. Optional public aliases use the same leftmost label under the configured public base host.
|
||||
|
||||
`<NAME>` is normalized from the script name by uppercasing it, replacing each run of non-`A-Z0-9` characters with `_`, and trimming leading or trailing `_`. For example, `app-server` and `app.server` both normalize to `APP_SERVER`; that collision fails at spawn time with an actionable error.
|
||||
|
||||
`PORT` is not injected by default. If a framework requires `PORT`, set it in the command:
|
||||
@@ -197,7 +113,7 @@ Service proxy hostnames use the double-dash shape: `web--feature-auth--project.l
|
||||
|
||||
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.
|
||||
|
||||
`npm run dev:server` builds the server-side workspace packages once, then keeps `@getpaseo/protocol` and `@getpaseo/client` fresh with TypeScript watch builds while the daemon runs. If you change protocol schemas or client code outside that watch workflow, rebuild the producer before trusting runtime behavior.
|
||||
`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:
|
||||
|
||||
@@ -216,26 +132,9 @@ For tighter loops, you can rebuild a single workspace:
|
||||
- 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`.
|
||||
|
||||
## ACP provider catalog versions
|
||||
|
||||
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
|
||||
and `uvx`) to exact package versions. Run the drift checker regularly — and
|
||||
before releases — so catalog installs do not sit on stale agent versions:
|
||||
|
||||
```bash
|
||||
npm run acp:version-drift # report stale/non-exact package pins
|
||||
npm run acp:version-drift:check # same, exits non-zero on drift
|
||||
npm run acp:version-drift:update # rewrite catalog pins to latest exact versions
|
||||
```
|
||||
|
||||
The checker updates only package-runner catalog entries. Providers that use a
|
||||
preinstalled binary such as `opencode acp`, `cursor-agent acp`, or `goose acp`
|
||||
are reported as skipped because their versions are owned by the user's local
|
||||
install.
|
||||
|
||||
## CLI reference
|
||||
|
||||
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
|
||||
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
|
||||
|
||||
```bash
|
||||
npm run cli -- ls -a -g # List all agents globally
|
||||
@@ -289,7 +188,7 @@ Get the session ID from the agent JSON (`persistence.sessionId`), then:
|
||||
|
||||
## Testing with Playwright MCP
|
||||
|
||||
Point Playwright MCP at the running Expo web target. For root checkout dev, `npm run dev:app` reserves `http://localhost:8081`. For Paseo-managed worktree app services, use the service URL or port shown by Paseo for that worktree.
|
||||
Point Playwright MCP at the running Expo web target. Under `npm run dev` (macOS/Linux) that is the portless URL printed in the dev banner — typically `https://app.localhost`. If you start Expo directly with `expo start --web` (no portless), Metro defaults to `http://localhost:8081`.
|
||||
|
||||
Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL — the app uses client-side routing and browser history breaks state.
|
||||
|
||||
|
||||
@@ -169,11 +169,6 @@ on that attach races Gorhom's dismiss path and leaves the modal unable to reopen
|
||||
Track an explicit phase (`closed` / `presenting` / `presented` / `dismissing`) and
|
||||
ignore ref churn while dismissing.
|
||||
|
||||
Do not treat `onChange(-1)` as a close by itself. In a stacked
|
||||
`BottomSheetModal`, `-1` can also mean the sheet is temporarily hidden under
|
||||
another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
|
||||
track phase.
|
||||
|
||||
## Recipe for a new anchored panel
|
||||
|
||||
Before you write a new one, ask:
|
||||
|
||||
+2
-3
@@ -15,11 +15,10 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **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, OMP). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **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 new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
|
||||
- **Heartbeat** — Cron-style prompt sent back into the same agent/conversation. MCP: `create_heartbeat`. Use for reminders and babysitting where the status should return inline.
|
||||
- **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.
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ Anyone who builds software:
|
||||
## Current state (May 2026)
|
||||
|
||||
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
|
||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi
|
||||
- One-click ACP provider catalog: Cursor, DeepSeek TUI, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||
- Voice mode: dictate prompts or talk through problems hands-free
|
||||
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
|
||||
|
||||
+2
-6
@@ -14,7 +14,7 @@ The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `Gen
|
||||
|
||||
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||
|
||||
@@ -24,16 +24,12 @@ 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`.
|
||||
|
||||
OMP is a built-in Pi-compatible provider, disabled by default. It uses the `omp` command and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. Other Pi-compatible forks can still be custom providers that extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
|
||||
|
||||
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.
|
||||
|
||||
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
|
||||
|
||||
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe by provider-visible message ID, not by text.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
|
||||
|
||||
---
|
||||
@@ -264,7 +260,7 @@ case "my-provider":
|
||||
);
|
||||
```
|
||||
|
||||
Add to the `allProviders` array (current built-ins are `claude`, `codex`, `copilot`, `opencode`, `pi`, `omp`):
|
||||
Add to the `allProviders` array (current built-ins are `claude`, `codex`, `copilot`, `opencode`, `pi`):
|
||||
|
||||
```ts
|
||||
export const allProviders: AgentProvider[] = [
|
||||
|
||||
+27
-53
@@ -9,9 +9,6 @@ A release has exactly two steps. The agent does the first, the user authorizes t
|
||||
**Preparation** (local, reversible — agent does this):
|
||||
|
||||
- format, lint, typecheck all green
|
||||
- ACP provider catalog drift checked with `npm run acp:version-drift:check`;
|
||||
if stale package-runner pins are intentional, say so explicitly, otherwise run
|
||||
`npm run acp:version-drift:update` and commit the updated catalog
|
||||
- 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
|
||||
@@ -33,7 +30,7 @@ Rules that apply to both steps:
|
||||
There are two supported ways to ship from `main`:
|
||||
|
||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||
2. **Beta flow**: silent release candidates. Betas don't touch the changelog, don't move the website, and publish npm only on the explicit `beta` dist-tag.
|
||||
2. **Beta flow**: silent release candidates. Betas don't touch the changelog, don't move the website, and don't publish npm or production mobile builds.
|
||||
|
||||
## Standard release (patch)
|
||||
|
||||
@@ -66,15 +63,14 @@ npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||
## Beta flow
|
||||
|
||||
```bash
|
||||
npm run release:beta:patch # Bump to X.Y.Z-beta.1, publish npm beta, push commit + tag
|
||||
npm run release:beta:patch # Bump to X.Y.Z-beta.1, push commit + tag
|
||||
# ... test desktop and APK prerelease assets from GitHub Releases ...
|
||||
npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
|
||||
npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
```
|
||||
|
||||
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
|
||||
- Betas publish npm packages with `--tag beta`, so `npm install @getpaseo/cli@beta` opts in while plain `npm install @getpaseo/cli` stays on `latest`
|
||||
- Betas publish desktop assets and APKs for testing, but they do not trigger the production web/mobile release flows
|
||||
- Betas publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
|
||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
|
||||
@@ -195,9 +191,6 @@ cd packages/app
|
||||
# Recent builds (newest first). Pipe to jq for status only.
|
||||
npx eas build:list --limit 8 --non-interactive --json | jq '.[] | {platform, status, appVersion, gitCommitHash}'
|
||||
|
||||
# Recent EAS workflow runs. This is the source of truth for submit/review jobs.
|
||||
npx eas workflow:runs --json | jq '.[] | {status, workflowName, trigger, gitCommitHash, startedAt, finishedAt}'
|
||||
|
||||
# Filter by platform.
|
||||
npx eas build:list --platform ios --limit 5 --non-interactive --json
|
||||
npx eas build:list --platform android --limit 5 --non-interactive --json
|
||||
@@ -205,52 +198,35 @@ npx eas build:list --platform android --limit 5 --non-interactive --json
|
||||
# Inspect a specific build.
|
||||
npx eas build:view <build-id>
|
||||
|
||||
# Inspect the full release workflow, including submit_ios, submit_android,
|
||||
# and submit_ios_for_review.
|
||||
npx eas workflow:view <workflow-run-id> --json
|
||||
|
||||
# Read failed submit/review job logs.
|
||||
npx eas workflow:logs <workflow-job-id> --all-steps --non-interactive
|
||||
|
||||
# Stream logs for a build.
|
||||
npx eas build:view <build-id> --json | jq '.logFiles[]'
|
||||
```
|
||||
|
||||
A build's `gitCommitHash` must match the release tag commit. `status` walks through `NEW` → `IN_QUEUE` → `IN_PROGRESS` → `FINISHED` (or `ERRORED`/`CANCELED`). The EAS workflow run's `gitCommitHash` and `trigger` must also match the release tag.
|
||||
A build's `gitCommitHash` must match the release tag commit. `status` walks through `NEW` → `IN_QUEUE` → `IN_PROGRESS` → `FINISHED` (or `ERRORED`/`CANCELED`).
|
||||
|
||||
Once a build is `FINISHED`, EAS still has release-critical work to do: Android must submit to the Play Store, and iOS must upload to TestFlight **and** submit the build for App Store review. The release is not done until all platforms are on their way through the stores.
|
||||
|
||||
For the `Release Mobile` EAS workflow, these jobs must pass:
|
||||
|
||||
- `build_ios` — iOS binary built
|
||||
- `submit_ios` — iOS binary uploaded to App Store Connect/TestFlight
|
||||
- `submit_ios_for_review` — iOS build submitted for App Store review via Fastlane
|
||||
- `build_android` — Android store binary built
|
||||
- `submit_android` — Android binary submitted to the Play Store
|
||||
|
||||
Do not treat `build_ios: SUCCESS` or `submit_ios: SUCCESS` as a completed iOS release. `submit_ios_for_review: FAILURE` means the iOS release is blocked even if the build is visible in TestFlight.
|
||||
|
||||
To confirm the submission landed, inspect the EAS workflow with `npx eas workflow:view <workflow-run-id> --json`. App Store Connect (review state for the matching version/build) and the Play Console track are the final ground truth.
|
||||
Once a build is `FINISHED`, EAS auto-submits it to the store: Android via the `submit` block in `eas.json` (EAS-managed Play Console credentials), iOS via the Fastlane `submit_review` lane (uploads to TestFlight, then submits for App Store review). To confirm the submission landed, run `npx eas build:view <build-id>` and open the `Logs` URL it prints — the build's Expo dashboard page has a Submissions section listing each attempt with its store response. App Store Connect (TestFlight tab → ready for review) and the Play Console (Internal testing / Production tracks) are the final ground truth.
|
||||
|
||||
### Babysitting mobile after a release
|
||||
|
||||
The user rarely opens the Expo dashboard. A failed EAS build or submit/review job can sit silently until users complain about a stale version. After every stable release, set up a long-delay babysit that re-checks GitHub Actions, EAS builds, and the EAS `Release Mobile` workflow for the release tag. If any build is `ERRORED`/`CANCELED`, any workflow is `FAILURE`, or any required submit/review job fails, surface it immediately. If all builds are `FINISHED` and all required submit/review jobs are `SUCCESS`, confirm and stop.
|
||||
The user rarely opens the Expo dashboard. A failed EAS build can sit silently until users complain about a stale version. After every stable release, set up a long-delay babysit that re-checks both EAS builds and GitHub Actions for the release tag. If anything is `ERRORED` or `FAILED`, surface it immediately. If everything is `FINISHED`/`SUCCESS`, confirm and stop.
|
||||
|
||||
**Use `create_heartbeat`, never `create_schedule`, for release babysitting.** Babysitting fires back into the current conversation as a wake-up prompt. `create_schedule` starts a fresh agent the user has to find and read; `create_heartbeat` surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `create_schedule` for a release babysit, you are about to ship a status report into a void.
|
||||
**Use a heartbeat schedule, never a new-agent schedule.** Babysitting fires back into the current conversation as a wake-up prompt — `target: "self"` in `mcp__paseo__create_schedule`. Never use `target: "new-agent"`. A new agent spawns a fresh conversation the user has to find and read; a heartbeat surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `new-agent` for a release babysit, you are about to ship a status report into a void.
|
||||
|
||||
Pattern:
|
||||
|
||||
```jsonc
|
||||
// mcp__paseo__create_heartbeat arguments
|
||||
// mcp__paseo__create_schedule arguments
|
||||
{
|
||||
"name": "vX.Y.Z release babysit heartbeat",
|
||||
"cron": "*/15 * * * *",
|
||||
"every": "15m",
|
||||
"maxRuns": 8, // covers ~2h of build + store-submission window
|
||||
"prompt": "Heartbeat: check vX.Y.Z release. Run gh run list, eas build:list, eas workflow:runs, and eas workflow:view for the matching Release Mobile run. Report concisely. The release is not done until desktop/APK workflows are green, EAS builds are FINISHED, Android submit_android is SUCCESS, and iOS submit_ios + submit_ios_for_review are SUCCESS. Flag any ERRORED/FAILED/CANCELED/FAILURE loudly.",
|
||||
"target": "self", // heartbeat, NOT "new-agent"
|
||||
"cwd": "/path/to/paseo",
|
||||
"prompt": "Heartbeat: check vX.Y.Z release builds. Run gh run list + eas build:list, report concisely; flag any ERRORED/FAILED/CANCELED.",
|
||||
}
|
||||
```
|
||||
|
||||
Tight cadence on purpose. The first run fires immediately, giving a near-real-time status check before the conversation closes. Subsequent runs at 15-minute intervals catch transitions quickly: a failed EAS build or failed App Store review submission at +20m should not wait until +50m to surface. Keep the prompt short — the heartbeat is a status probe, not a research task — and have it bail out as soon as every platform is actually on its store path so the remaining runs do not generate noise.
|
||||
Tight cadence on purpose. The first run fires immediately, giving a near-real-time status check before the conversation closes. Subsequent runs at 15-minute intervals catch transitions quickly: a failed EAS build that errors at +20m should not wait until +50m to surface. Keep the prompt short — the heartbeat is a status probe, not a research task — and have it bail out as soon as everything is green so the remaining runs do not generate noise.
|
||||
|
||||
## Release notes on GitHub
|
||||
|
||||
@@ -304,7 +280,6 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
|
||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||
- If `release:publish:beta` partially fails, re-run it — npm skips already-published versions and keeps prereleases off `latest` because every publish uses `--tag beta`
|
||||
- The website uses GitHub's latest published release API for download links, so published beta prereleases do not replace the stable download target.
|
||||
|
||||
## Changelog format
|
||||
@@ -325,8 +300,8 @@ No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to
|
||||
|
||||
## Changelog ownership
|
||||
|
||||
- **The agent running the stable release writes the changelog entry.** Do not hand the changelog to another model or agent. The release agent has the release context and owns the final wording.
|
||||
- Draft the entry from the stable-to-stable diff, review it against the changelog policy below, show it to the user, and wait for approval before committing it.
|
||||
- **Only Claude should write changelog entries.**
|
||||
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
|
||||
|
||||
## Changelog voice
|
||||
|
||||
@@ -405,15 +380,19 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
|
||||
|
||||
## Pre-release sanity check
|
||||
|
||||
Before cutting a **stable** release, the release agent reviews the diff as a last line of defence against shipping bugs. Skip this for betas — the beta itself is the smoke test, and gating each beta on a code review defeats the point of using betas as fast release candidates.
|
||||
Before cutting a **stable** release, run a Codex review of the diff as a last line of defence against shipping bugs. Skip this for betas — the beta itself is the smoke test, and gating each beta on a code review defeats the point of using betas as fast release candidates.
|
||||
|
||||
Review the diff between the latest release tag and `HEAD`. Focus on:
|
||||
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
|
||||
|
||||
1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
|
||||
2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
|
||||
3. **Regressions** — anything that looks like it could break existing functionality.
|
||||
> Review the diff between the latest release tag and HEAD. Focus on:
|
||||
>
|
||||
> 1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
|
||||
> 2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
|
||||
> 3. **Regressions** — anything that looks like it could break existing functionality.
|
||||
>
|
||||
> Diff: `git diff <latest-release-tag>..HEAD`
|
||||
|
||||
Use `git diff <latest-release-tag>..HEAD` as the review input. This is a deep sanity check, not a full code review. If anything looks risky, investigate before proceeding and surface the finding to the user.
|
||||
The agent's job is a deep sanity check, not a full code review. If it flags anything, investigate before proceeding.
|
||||
|
||||
## Changelog scope
|
||||
|
||||
@@ -425,7 +404,6 @@ The changelog covers **stable-to-stable**. Betas are not represented. When you p
|
||||
|
||||
- [ ] Working tree is clean and the intended commit is on `main`
|
||||
- [ ] `npm run release:beta:patch` (or `:next`) completes successfully
|
||||
- [ ] npm shows the version under the `beta` dist-tag, not `latest`
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
|
||||
@@ -439,9 +417,5 @@ The changelog covers **stable-to-stable**. Betas are not represented. When you p
|
||||
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
- [ ] EAS `Release Mobile` workflow for the same tag is green
|
||||
- [ ] EAS iOS `build_ios` completes for the same tag
|
||||
- [ ] EAS iOS `submit_ios` succeeds, uploading the build to App Store Connect/TestFlight
|
||||
- [ ] EAS iOS `submit_ios_for_review` succeeds, putting the build into App Store review
|
||||
- [ ] EAS Android `build_android` completes for the same tag
|
||||
- [ ] EAS Android `submit_android` succeeds, putting the build on its Play Store track
|
||||
- [ ] EAS iOS production build for the same tag completes and submits via Fastlane
|
||||
- [ ] EAS Android production build for the same tag completes and auto-submits to the Play Store
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
# Service Proxy
|
||||
|
||||
Paseo proxies HTTP traffic to services running inside your workspaces. Localhost service URLs are always enabled; optional public aliases and a separate service-only listener can be layered on through config.
|
||||
|
||||
## How it works
|
||||
|
||||
When a `paseo.json` script of `"type": "service"` starts, Paseo assigns it a local port and registers a route in the service proxy. Incoming requests whose `Host` header matches the script's generated hostname are forwarded to that port.
|
||||
|
||||
The generated hostname is built from the script name, branch, and project:
|
||||
|
||||
```
|
||||
<script>--<branch>--<project>.localhost
|
||||
```
|
||||
|
||||
If the branch is `main` or `master`, the branch segment is omitted:
|
||||
|
||||
```
|
||||
<script>--<project>.localhost
|
||||
```
|
||||
|
||||
**Example:** a script named `dev` in the `miniweb` project on branch `feature/auth` would be reachable at:
|
||||
|
||||
```
|
||||
dev--feature-auth--miniweb.localhost
|
||||
```
|
||||
|
||||
Local and public routes use one combined leftmost label (`script--branch--project`). This keeps the hostname compatible with normal single-level wildcard DNS and TLS. If the combined label would exceed DNS's 63-character label limit, Paseo truncates it with a deterministic hash suffix to avoid collisions.
|
||||
|
||||
## Configuration
|
||||
|
||||
Add a `serviceProxy` block under `daemon` in `~/.paseo/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"daemon": {
|
||||
"serviceProxy": {
|
||||
"listen": "0.0.0.0:8080",
|
||||
"publicBaseUrl": "https://paseoapps.my.domain.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
| --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `listen` | No | Starts a separate service-only listener at this address. If omitted, services are still reachable on the daemon listener via localhost hosts. |
|
||||
| `publicBaseUrl` | No | Adds public service host aliases and public service links. If omitted, links use localhost addresses only. |
|
||||
|
||||
`enabled` is accepted for old configs but no longer enables a mode. `enabled: false` suppresses optional `listen`/`publicBaseUrl` layers only; localhost service proxying remains always enabled.
|
||||
|
||||
## DNS and reverse proxy setup
|
||||
|
||||
For generated URLs to be reachable, you need wildcard DNS pointing to the machine running the Paseo daemon.
|
||||
|
||||
**Example:** to expose services at `https://dev--miniweb.paseoapps.my.domain.com` where the daemon host is `10.1.1.1`:
|
||||
|
||||
1. Configure a wildcard DNS record:
|
||||
|
||||
```
|
||||
*.paseoapps.my.domain.com → 10.1.1.1
|
||||
```
|
||||
|
||||
2. Set `publicBaseUrl` to `https://paseoapps.my.domain.com` in your config.
|
||||
|
||||
3. If you put a reverse proxy (nginx, Caddy, Traefik, etc.) in front of Paseo, point it at either the daemon listener or the optional service-only listener and ensure it forwards the `Host` header unchanged. The proxy uses the `Host` header to route requests to the correct service — rewriting it will break routing.
|
||||
|
||||
Public service URLs expose the workspace service itself. Daemon password authentication protects daemon APIs; it does not protect proxied dev services.
|
||||
|
||||
Nginx example:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name *.paseoapps.my.domain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://10.1.1.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
The listen address and public base URL can also be set via environment variables, which take precedence over `config.json`:
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| `PASEO_SERVICE_PROXY_ENABLED` | Compatibility shim; `false` suppresses optional public/listen layers only |
|
||||
| `PASEO_SERVICE_PROXY_LISTEN` | Starts the optional service-only listener, e.g. `0.0.0.0:8080` |
|
||||
| `PASEO_SERVICE_PROXY_PUBLIC_BASE_URL` | Adds public service aliases and links |
|
||||
@@ -88,8 +88,6 @@ Do not split a component into plain and Unistyles variants just to handle one hi
|
||||
|
||||
When a reusable component has a prop whose whole job is dynamic geometry, make that prop the seam. For example, `FloatingSurface.frameStyle` and `FloatingScrollView.style` own their own escape hatch so menu, tooltip, hover-card, and combobox callers can stay declarative instead of knowing about Unistyles internals.
|
||||
|
||||
Do not flatten a caller-provided style array and pass the flattened object back to a React Native component. Unistyles style entries carry `unistyles_*` metadata; flattening two entries produces one object with multiple metadata keys and triggers the runtime warning: "use array syntax instead of object syntax." Preserve caller styles as arrays, and only flatten the dynamic geometry value you explicitly own. If that owned value was flattened from a mixed style prop, strip `unistyles_*` metadata before sending it through `inlineUnistylesStyle`.
|
||||
|
||||
## Main Gotcha: `contentContainerStyle`
|
||||
|
||||
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
|
||||
@@ -291,21 +289,6 @@ That brief transition is expected with the current storage model. It makes track
|
||||
|
||||
If we ever need to avoid the transition entirely, store at least the theme preference in synchronous storage and configure Unistyles with `initialTheme`.
|
||||
|
||||
## Runtime Theme Patching For User Preferences
|
||||
|
||||
Appearance settings (UI/mono font family, font sizes, syntax-highlight theme) are applied by patching every registered theme at runtime with `UnistylesRuntime.updateTheme(name, updater)` — not by threading preference reads through components. `applyAppearance` in `packages/app/src/screens/settings/appearance/apply-appearance.ts` runs from a `ProvidersWrapper` effect on settings load/change and loops all six theme keys, returning `{ ...theme, fontFamily, fontSize, lineHeight, colors.syntax }`.
|
||||
|
||||
This works without `useUnistyles()` because every consumer already reads these tokens through `StyleSheet.create((theme) => …)` (or the `withUnistyles`/`uniProps` path for the markdown renderer), so patching the theme repaints tracked views through the native ShadowRegistry with no React re-render.
|
||||
|
||||
Gotchas:
|
||||
|
||||
- **Patch all themes, not just the active one.** The active theme can change and adaptive mode can flip light/dark; patching every key keeps the active key current and makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant. The effect depends on the settings values (not on `theme`), so it cannot loop.
|
||||
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
|
||||
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with the UI ramp (`Math.round(theme.fontSize.base * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
|
||||
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
|
||||
- **Mounted parsed content uses `AppearanceStyleBoundary`.** Markdown, syntax-highlighted code, and tool-call detail bodies can contain memoized/custom renderer trees that do not naturally re-run when runtime-patched appearance tokens change. Wrap the parsed surface once with `packages/app/src/components/appearance-style-boundary.tsx`; do not add local "appearance key" props at each callsite.
|
||||
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
|
||||
|
||||
## Debugging
|
||||
|
||||
To inspect what the Babel plugin sees, temporarily enable [`debug: true`](https://www.unistyl.es/v3/other/babel-plugin#debug) in `packages/app/babel.config.js`:
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-EpxQim3vnwfPLPi8IAtF82tGrLNZiZP8IwN7rR9P7xo=
|
||||
sha256-ngMXsN0UDo4ldTMQgDQ4r6XfAvwnwh4hzglIcmGYu9E=
|
||||
|
||||
Generated
+21
-91
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.87",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.87",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -2813,50 +2813,6 @@
|
||||
"integrity": "sha512-jFlTTD+0MK/01TdL5sHIsQ8RqzfmvBsGl4hSp87INv2+JIs/JF6EL9J8enuCz6z3fNdfOKISNbGCIrzZRXVrcw==",
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
||||
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/legacy-modes": {
|
||||
"version": "6.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz",
|
||||
"integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
|
||||
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
|
||||
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
@@ -7918,12 +7874,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||
@@ -17029,12 +16979,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||
@@ -34089,12 +34033,6 @@
|
||||
"integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/style-to-js": {
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
|
||||
@@ -36089,12 +36027,6 @@
|
||||
"integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
|
||||
@@ -36953,7 +36885,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.87",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -37178,12 +37110,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.87",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.91",
|
||||
"@getpaseo/protocol": "0.1.91",
|
||||
"@getpaseo/server": "0.1.91",
|
||||
"@getpaseo/client": "0.1.87",
|
||||
"@getpaseo/protocol": "0.1.87",
|
||||
"@getpaseo/server": "0.1.87",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37429,10 +37361,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.87",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.91",
|
||||
"@getpaseo/relay": "0.1.91",
|
||||
"@getpaseo/protocol": "0.1.87",
|
||||
"@getpaseo/relay": "0.1.87",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37452,7 +37384,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.87",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37704,7 +37636,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.87",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -37740,10 +37672,8 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.87",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
"@lezer/css": "^1.3.0",
|
||||
@@ -37971,7 +37901,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.87",
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -37992,7 +37922,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.87",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38210,14 +38140,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.87",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/client": "0.1.91",
|
||||
"@getpaseo/highlight": "0.1.91",
|
||||
"@getpaseo/protocol": "0.1.91",
|
||||
"@getpaseo/relay": "0.1.91",
|
||||
"@getpaseo/client": "0.1.87",
|
||||
"@getpaseo/highlight": "0.1.87",
|
||||
"@getpaseo/protocol": "0.1.87",
|
||||
"@getpaseo/relay": "0.1.87",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38989,7 +38919,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.87",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+12
-25
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.87",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
@@ -34,30 +34,22 @@
|
||||
"packages/cli"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "npm run dev:server",
|
||||
"dev": "./scripts/dev.sh",
|
||||
"dev:win": "powershell ./scripts/dev.ps1",
|
||||
"dev:server": "cross-env PASEO_LISTEN=127.0.0.1:6768 ./scripts/dev-daemon.sh",
|
||||
"dev:server:watch": "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": "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": "cross-env PASEO_LISTEN=127.0.0.1:6768 EXPO_PORT=8081 ./scripts/dev-app.sh",
|
||||
"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:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight",
|
||||
"build:relay": "npm run build --workspace=@getpaseo/relay",
|
||||
"build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay",
|
||||
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
|
||||
"build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol",
|
||||
"build:client": "npm run build:protocol && npm run build --workspace=@getpaseo/client",
|
||||
"build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
|
||||
"build:server-deps": "npm run build:highlight && npm run build:relay && npm run build:client",
|
||||
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
|
||||
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
|
||||
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && 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",
|
||||
@@ -70,9 +62,6 @@
|
||||
"lint": "oxlint",
|
||||
"lint:fix": "oxlint --fix",
|
||||
"knip": "knip",
|
||||
"acp:version-drift": "node scripts/check-acp-catalog-version-drift.mjs",
|
||||
"acp:version-drift:check": "node scripts/check-acp-catalog-version-drift.mjs --fail-on-drift",
|
||||
"acp:version-drift:update": "node scripts/check-acp-catalog-version-drift.mjs --update",
|
||||
"start": "npm run start --workspace=@getpaseo/server",
|
||||
"android": "npm run android --workspace=@getpaseo/app",
|
||||
"android:development": "npm run android:development --workspace=@getpaseo/app",
|
||||
@@ -82,11 +71,11 @@
|
||||
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
|
||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
"dev:desktop": "cross-env PASEO_LISTEN=127.0.0.1:6768 npm run dev --workspace=@getpaseo/desktop",
|
||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
|
||||
"build:desktop": "npm run build:app-deps:clean && 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": "./scripts/dev-home.sh npx tsx packages/cli/src/index.js",
|
||||
"cli": "npx tsx packages/cli/src/index.js",
|
||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||
"release:prepare": "npm install --workspaces --include-workspace-root",
|
||||
@@ -98,16 +87,14 @@
|
||||
"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 typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run build:server:clean && 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: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:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta",
|
||||
"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:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta",
|
||||
"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:publish:beta && npm run release:push",
|
||||
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:publish:beta && npm run release:push",
|
||||
"release:beta:major": "npm run release:check && npm run version:all:beta:major && npm run release:publish:beta && npm run release:push",
|
||||
"release:beta:next": "npm run release:check && npm run version:all:beta:next && npm run release:publish:beta && npm run release:push",
|
||||
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:push",
|
||||
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:push",
|
||||
"release:beta:major": "npm run release:check && npm run version:all:beta:major && npm run release:push",
|
||||
"release:beta:next": "npm run release:check && npm run version:all:beta:next && npm run release:push",
|
||||
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
|
||||
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
||||
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
expectTimelinePromptNotMounted,
|
||||
expectTimelinePromptVisible,
|
||||
openAgentTimeline,
|
||||
scrollTimelineUntilOlderHistoryIsReachable,
|
||||
seedLongMockAgentTimeline,
|
||||
} from "./helpers/timeline-pagination";
|
||||
|
||||
test.describe("Agent timeline pagination", () => {
|
||||
test("loads older history when the user scrolls to the top of a long agent timeline", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||
try {
|
||||
await openAgentTimeline(page, agent);
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
|
||||
|
||||
await scrollTimelineUntilOlderHistoryIsReachable(page);
|
||||
|
||||
await expectTimelinePromptVisible(page, agent.oldestPrompt);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,505 +0,0 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
|
||||
interface DirtyWorkspace {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface CleanupTask {
|
||||
run: () => Promise<void>;
|
||||
}
|
||||
|
||||
const cleanupTasks: CleanupTask[] = [];
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
|
||||
|
||||
const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface UseMountedTabSetInput {
|
||||
activeTabId: string | null;
|
||||
allTabIds: string[];
|
||||
cap: number;
|
||||
}
|
||||
|
||||
interface UseMountedTabSetResult {
|
||||
mountedTabIds: Set<string>;
|
||||
}
|
||||
|
||||
function createInitialMountedTabIds(input: UseMountedTabSetInput): Set<string> {
|
||||
if (!input.activeTabId || !input.allTabIds.includes(input.activeTabId)) {
|
||||
return new Set<string>();
|
||||
}
|
||||
return new Set<string>([input.activeTabId]);
|
||||
}
|
||||
|
||||
function setsEqual(left: Set<string>, right: Set<string>): boolean {
|
||||
if (left.size !== right.size) {
|
||||
return false;
|
||||
}
|
||||
for (const value of left) {
|
||||
if (!right.has(value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSetResult {
|
||||
const { activeTabId, allTabIds, cap } = input;
|
||||
const allTabIdsKey = allTabIds.join("\\u0000");
|
||||
const availableTabIds = useMemo(() => {
|
||||
void allTabIdsKey;
|
||||
return new Set(allTabIds);
|
||||
}, [allTabIds, allTabIdsKey]);
|
||||
const [mountedTabIds, setMountedTabIds] = useState(() => createInitialMountedTabIds(input));
|
||||
const lruRef = useRef(activeTabId && allTabIds.includes(activeTabId) ? [activeTabId] : []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const nextLru = lruRef.current.filter((tabId) => availableTabIds.has(tabId));
|
||||
if (activeTabId && availableTabIds.has(activeTabId)) {
|
||||
const existingIndex = nextLru.indexOf(activeTabId);
|
||||
if (existingIndex >= 0) {
|
||||
nextLru.splice(existingIndex, 1);
|
||||
}
|
||||
nextLru.unshift(activeTabId);
|
||||
}
|
||||
if (nextLru.length > cap) {
|
||||
nextLru.length = cap;
|
||||
}
|
||||
|
||||
lruRef.current = nextLru;
|
||||
setMountedTabIds((previousMountedTabIds) => {
|
||||
const nextMountedTabIds = new Set(nextLru);
|
||||
return setsEqual(previousMountedTabIds, nextMountedTabIds)
|
||||
? previousMountedTabIds
|
||||
: nextMountedTabIds;
|
||||
});
|
||||
}, [activeTabId, availableTabIds, cap]);
|
||||
|
||||
return { mountedTabIds };
|
||||
}
|
||||
`;
|
||||
|
||||
const AFTER = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface UseMountedTabSetInput {
|
||||
activeTabId: string | null;
|
||||
allTabIds: string[];
|
||||
cap: number;
|
||||
}
|
||||
|
||||
interface UseMountedTabSetResult {
|
||||
mountedTabIds: Set<string>;
|
||||
}
|
||||
|
||||
interface DeriveRenderMountedTabIdsInput {
|
||||
activeTabId: string | null;
|
||||
availableTabIds: Set<string>;
|
||||
cap: number;
|
||||
mountedTabIds: Set<string>;
|
||||
}
|
||||
|
||||
function createInitialMountedTabIds(input: UseMountedTabSetInput): Set<string> {
|
||||
if (!input.activeTabId || !input.allTabIds.includes(input.activeTabId)) {
|
||||
return new Set<string>();
|
||||
}
|
||||
return new Set<string>([input.activeTabId]);
|
||||
}
|
||||
|
||||
function setsEqual(left: Set<string>, right: Set<string>): boolean {
|
||||
if (left.size !== right.size) {
|
||||
return false;
|
||||
}
|
||||
for (const value of left) {
|
||||
if (!right.has(value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function deriveRenderMountedTabIds(input: DeriveRenderMountedTabIdsInput): Set<string> {
|
||||
const { activeTabId, availableTabIds, cap, mountedTabIds } = input;
|
||||
if (!activeTabId || !availableTabIds.has(activeTabId) || mountedTabIds.has(activeTabId)) {
|
||||
return mountedTabIds;
|
||||
}
|
||||
|
||||
const next = new Set<string>([activeTabId]);
|
||||
const maxSize = Math.max(1, cap);
|
||||
for (const tabId of mountedTabIds) {
|
||||
if (next.size >= maxSize) {
|
||||
break;
|
||||
}
|
||||
if (availableTabIds.has(tabId)) {
|
||||
next.add(tabId);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSetResult {
|
||||
const { activeTabId, allTabIds, cap } = input;
|
||||
const allTabIdsKey = allTabIds.join("\\u0000");
|
||||
const availableTabIds = useMemo(() => {
|
||||
void allTabIdsKey;
|
||||
return new Set(allTabIds);
|
||||
}, [allTabIds, allTabIdsKey]);
|
||||
const [mountedTabIds, setMountedTabIds] = useState(() => createInitialMountedTabIds(input));
|
||||
const lruRef = useRef(activeTabId && allTabIds.includes(activeTabId) ? [activeTabId] : []);
|
||||
const renderMountedTabIds = useMemo(
|
||||
() =>
|
||||
deriveRenderMountedTabIds({
|
||||
activeTabId,
|
||||
availableTabIds,
|
||||
cap,
|
||||
mountedTabIds,
|
||||
}),
|
||||
[activeTabId, availableTabIds, cap, mountedTabIds],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const nextLru = lruRef.current.filter((tabId) => availableTabIds.has(tabId));
|
||||
if (activeTabId && availableTabIds.has(activeTabId)) {
|
||||
const existingIndex = nextLru.indexOf(activeTabId);
|
||||
if (existingIndex >= 0) {
|
||||
nextLru.splice(existingIndex, 1);
|
||||
}
|
||||
nextLru.unshift(activeTabId);
|
||||
}
|
||||
if (nextLru.length > cap) {
|
||||
nextLru.length = cap;
|
||||
}
|
||||
|
||||
lruRef.current = nextLru;
|
||||
setMountedTabIds((previousMountedTabIds) => {
|
||||
const nextMountedTabIds = new Set(nextLru);
|
||||
return setsEqual(previousMountedTabIds, nextMountedTabIds)
|
||||
? previousMountedTabIds
|
||||
: nextMountedTabIds;
|
||||
});
|
||||
}, [activeTabId, availableTabIds, cap]);
|
||||
|
||||
return { mountedTabIds: renderMountedTabIds };
|
||||
}
|
||||
`;
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const task of cleanupTasks.splice(0)) {
|
||||
await task.run();
|
||||
}
|
||||
});
|
||||
|
||||
test("changes diff keeps code rows aligned with the gutter", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useCodeFont(page, 9);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await expectDiffCodeFontSize(page, 9);
|
||||
await expectVisibleDiffRowsAligned(page);
|
||||
await expectDiffCodeTextAlignedWithGutterText(page, [
|
||||
{
|
||||
codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)",
|
||||
lineNumber: "20",
|
||||
},
|
||||
{ codeText: "return next;", lineNumber: "55" },
|
||||
{ codeText: "useLayoutEffect(() => {", lineNumber: "78" },
|
||||
]);
|
||||
await expectHoverCommentButtonAlignedWithCodeLine(page, {
|
||||
codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)",
|
||||
lineNumber: "20",
|
||||
});
|
||||
});
|
||||
|
||||
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useCodeFont(page, 12);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await changeCodeFontSizeFromSettings(page, 18);
|
||||
await returnToWorkspaceChanges(page);
|
||||
await scrollToLowerUnwrappedDiffRows(page);
|
||||
|
||||
await expectDiffCodeFontSize(page, 18);
|
||||
await expectVisibleDiffRowsShareTypography(page);
|
||||
await expectVisibleDiffRowsAligned(page);
|
||||
});
|
||||
|
||||
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ settingsKey, fontSize }) => {
|
||||
localStorage.setItem(
|
||||
settingsKey,
|
||||
JSON.stringify({
|
||||
theme: "dark",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
terminalScrollbackLines: 10_000,
|
||||
uiFontFamily: "",
|
||||
monoFontFamily: "",
|
||||
uiFontSize: 16,
|
||||
codeFontSize: fontSize,
|
||||
syntaxTheme: "one",
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ settingsKey: APP_SETTINGS_KEY, fontSize: codeFontSize },
|
||||
);
|
||||
}
|
||||
|
||||
async function useUnwrappedDiffLines(page: Page): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ preferencesKey }) => {
|
||||
localStorage.setItem(
|
||||
preferencesKey,
|
||||
JSON.stringify({ layout: "unified", wrapLines: false, hideWhitespace: false }),
|
||||
);
|
||||
},
|
||||
{ preferencesKey: CHANGES_PREFERENCES_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
|
||||
const actualFontSize = await page
|
||||
.getByTestId("diff-code-text-1")
|
||||
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
|
||||
expect(actualFontSize).toBe(fontSize);
|
||||
}
|
||||
|
||||
async function expectVisibleDiffRowsAligned(page: Page): Promise<void> {
|
||||
const geometry = await readVisibleDiffRowGeometry(page);
|
||||
expect(geometry.maxDelta, JSON.stringify(geometry.rows, null, 2)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
async function expectVisibleDiffRowsShareTypography(page: Page): Promise<void> {
|
||||
const geometry = await readVisibleDiffRowGeometry(page);
|
||||
expect(geometry.mismatchedTypography, JSON.stringify(geometry, null, 2)).toEqual([]);
|
||||
}
|
||||
|
||||
async function readVisibleDiffRowGeometry(page: Page): Promise<{
|
||||
maxDelta: number;
|
||||
mismatchedTypography: { index: number; gutterLineHeight: number; codeLineHeight: number }[];
|
||||
rows: {
|
||||
index: number;
|
||||
gutterTop: number;
|
||||
codeTop: number;
|
||||
delta: number;
|
||||
gutterLineHeight: number;
|
||||
codeLineHeight: number;
|
||||
}[];
|
||||
}> {
|
||||
return page.locator("body").evaluate(({ ownerDocument }) => {
|
||||
const root = ownerDocument.querySelector('[data-testid="diff-file-0-body"]');
|
||||
if (!root) {
|
||||
throw new Error("Expanded diff body is not mounted");
|
||||
}
|
||||
|
||||
const readRows = (prefix: string, textPrefix: string) =>
|
||||
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => {
|
||||
const testId = row.getAttribute("data-testid") ?? "";
|
||||
const index = Number(testId.slice(prefix.length));
|
||||
const rect = row.getBoundingClientRect();
|
||||
const text = root.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`);
|
||||
const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0;
|
||||
return { index, top: rect.top, height: rect.height, lineHeight };
|
||||
});
|
||||
|
||||
const gutters = new Map(
|
||||
readRows("diff-gutter-row-", "diff-gutter-text-").map((row) => [row.index, row]),
|
||||
);
|
||||
const codes = readRows("diff-code-row-", "diff-code-text-");
|
||||
const rows = codes
|
||||
.map((code) => {
|
||||
const gutter = gutters.get(code.index);
|
||||
if (!gutter) {
|
||||
throw new Error(`Missing gutter row ${code.index}`);
|
||||
}
|
||||
return {
|
||||
index: code.index,
|
||||
gutterTop: gutter.top,
|
||||
codeTop: code.top,
|
||||
delta: Math.abs(code.top - gutter.top),
|
||||
gutterLineHeight: gutter.lineHeight,
|
||||
codeLineHeight: code.lineHeight,
|
||||
};
|
||||
})
|
||||
.filter((row) => row.gutterTop >= 0 && row.codeTop >= 0);
|
||||
|
||||
return {
|
||||
maxDelta: Math.max(...rows.map((row) => row.delta)),
|
||||
mismatchedTypography: rows
|
||||
.filter((row) => Math.abs(row.gutterLineHeight - row.codeLineHeight) > 0.5)
|
||||
.map((row) => ({
|
||||
index: row.index,
|
||||
gutterLineHeight: row.gutterLineHeight,
|
||||
codeLineHeight: row.codeLineHeight,
|
||||
})),
|
||||
rows,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("diff-row-alignment-", {
|
||||
files: [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }],
|
||||
});
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
},
|
||||
});
|
||||
|
||||
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
const opened = await client.openProject(repo.path);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${repo.path}`);
|
||||
}
|
||||
return { id: opened.workspace.id };
|
||||
}
|
||||
|
||||
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await openChangesInVisibleExplorer(page);
|
||||
await page.getByTestId("diff-file-0").click();
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
}
|
||||
|
||||
async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("explorer-tab-changes")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function changeCodeFontSizeFromSettings(page: Page, codeFontSize: number): Promise<void> {
|
||||
await page.getByTestId("sidebar-settings").click();
|
||||
await expect(page).toHaveURL(new RegExp(`${buildSettingsSectionRoute("general")}|/settings$`));
|
||||
await page.getByRole("button", { name: "Appearance" }).click();
|
||||
await page.getByLabel("Code font size").fill(String(codeFontSize));
|
||||
await page.getByLabel("Code font size").press("Enter");
|
||||
await expect(page.getByLabel("Code font size")).toHaveValue(String(codeFontSize));
|
||||
}
|
||||
|
||||
async function returnToWorkspaceChanges(page: Page): Promise<void> {
|
||||
await page.getByTestId("settings-back-to-workspace").click();
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesInVisibleExplorer(page);
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
}
|
||||
|
||||
async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
|
||||
const lastRowIndex = await page.getByTestId("diff-file-0-body").evaluate((root) => {
|
||||
const rows = Array.from(root.querySelectorAll<HTMLElement>('[data-testid^="diff-code-row-"]'));
|
||||
if (rows.length === 0) {
|
||||
throw new Error("No unwrapped code rows are mounted");
|
||||
}
|
||||
return Math.max(
|
||||
...rows.map((row) => Number((row.getAttribute("data-testid") ?? "").slice(14))),
|
||||
);
|
||||
});
|
||||
await page.getByTestId(`diff-code-row-${lastRowIndex}`).scrollIntoViewIfNeeded();
|
||||
await expect(page.getByTestId(`diff-code-row-${lastRowIndex}`)).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectDiffCodeTextAlignedWithGutterText(
|
||||
page: Page,
|
||||
lines: { codeText: string; lineNumber: string }[],
|
||||
): Promise<void> {
|
||||
const geometries = await readDiffTextGeometry(page, lines);
|
||||
for (const geometry of geometries) {
|
||||
expect(geometry.codeTop, geometry.codeText).toBeCloseTo(geometry.gutterTop, 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectHoverCommentButtonAlignedWithCodeLine(
|
||||
page: Page,
|
||||
line: { codeText: string; lineNumber: string },
|
||||
): Promise<void> {
|
||||
const target = await readDiffTextGeometry(page, [line]).then((rows) => rows[0]);
|
||||
if (!target) {
|
||||
throw new Error(`Could not find target line ${line.lineNumber}`);
|
||||
}
|
||||
await page.getByTestId(`diff-code-row-${target.index}`).hover();
|
||||
const geometry = await page
|
||||
.getByTestId(`diff-gutter-action-${target.index}`)
|
||||
.evaluate((action, expectedCodeCenterY) => {
|
||||
const rect = action.getBoundingClientRect();
|
||||
return {
|
||||
actionCenterY: rect.top + rect.height / 2,
|
||||
codeCenterY: expectedCodeCenterY,
|
||||
};
|
||||
}, target.codeCenterY);
|
||||
expect(geometry.actionCenterY).toBeCloseTo(geometry.codeCenterY, 0);
|
||||
}
|
||||
|
||||
async function readDiffTextGeometry(
|
||||
page: Page,
|
||||
lines: { codeText: string; lineNumber: string }[],
|
||||
): Promise<
|
||||
{ index: number; codeText: string; codeTop: number; gutterTop: number; codeCenterY: number }[]
|
||||
> {
|
||||
return page.locator("body").evaluate(({ ownerDocument }, targets) => {
|
||||
const root = ownerDocument.querySelector('[data-testid="explorer-content-area"]');
|
||||
if (!root) {
|
||||
throw new Error("Changes panel is not mounted");
|
||||
}
|
||||
|
||||
const readIndexedElements = (prefix: string) =>
|
||||
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map(
|
||||
(element) => {
|
||||
const testId = element.getAttribute("data-testid") ?? "";
|
||||
return { index: Number(testId.slice(prefix.length)), element };
|
||||
},
|
||||
);
|
||||
|
||||
const gutterTexts = readIndexedElements("diff-gutter-text-");
|
||||
const codeTexts = readIndexedElements("diff-code-text-");
|
||||
|
||||
return targets.map((target) => {
|
||||
const gutter = gutterTexts.find(
|
||||
({ element }) => (element.textContent ?? "").trim() === target.lineNumber,
|
||||
);
|
||||
if (!gutter) {
|
||||
throw new Error(`Could not find gutter line ${target.lineNumber}`);
|
||||
}
|
||||
const code = codeTexts.find(
|
||||
({ index, element }) =>
|
||||
index === gutter.index && (element.textContent ?? "").includes(target.codeText),
|
||||
);
|
||||
if (!code) {
|
||||
throw new Error(`Could not find code row ${target.codeText}`);
|
||||
}
|
||||
|
||||
const codeRect = code.element.getBoundingClientRect();
|
||||
const gutterRect = gutter.element.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
index: gutter.index,
|
||||
codeText: target.codeText,
|
||||
codeTop: codeRect.top,
|
||||
gutterTop: gutterRect.top,
|
||||
codeCenterY: codeRect.top + codeRect.height / 2,
|
||||
};
|
||||
});
|
||||
}, lines);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { waitForTabBar } from "./helpers/launcher";
|
||||
import { setupDeterministicPrompt, waitForTerminalContent } from "./helpers/terminal-perf";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { openFileExplorer, openFileFromExplorer } from "./helpers/file-explorer";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
openHomeWithProject,
|
||||
seedProjectForWorkspaceSetup,
|
||||
} from "./helpers/workspace-setup";
|
||||
import { buildHostWorkspaceRoute } from "../src/utils/host-routes";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function findShortcut(): string {
|
||||
return process.platform === "darwin" ? "Meta+f" : "Control+f";
|
||||
}
|
||||
|
||||
async function navigateToWorkspaceViaSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
await waitForTabBar(page);
|
||||
}
|
||||
|
||||
async function openFind(page: Page): Promise<void> {
|
||||
await page.keyboard.press(findShortcut());
|
||||
await expect(page.getByTestId("pane-find-bar")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function typeFindQuery(page: Page, query: string): Promise<void> {
|
||||
const input = page.getByTestId("pane-find-input");
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
await input.fill(query);
|
||||
}
|
||||
|
||||
test.describe("in-pane find", () => {
|
||||
test("walks chat, file, terminal, split-pane, and browser-web find flows in the running app", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const agentClient = await connectSeedClient();
|
||||
const repo = await createTempGitRepo("find-pane-qa-", {
|
||||
files: [
|
||||
{
|
||||
path: "src/find-target.txt",
|
||||
content: [
|
||||
"alpha needle first",
|
||||
"beta without match",
|
||||
"gamma NEEDLE second",
|
||||
"delta needle third",
|
||||
"",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const workspaceResult = await client.openProject(repo.path);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||
}
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceResult.workspace.id);
|
||||
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, "src");
|
||||
await openFileFromExplorer(page, "find-target.txt");
|
||||
await expect(page.getByTestId("workspace-file-pane")).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("workspace-file-pane").click();
|
||||
|
||||
await openFind(page);
|
||||
await typeFindQuery(page, "needle");
|
||||
await expect(page.getByText("1 / 3")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pane-find-next").click();
|
||||
await expect(page.getByText("2 / 3")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pane-find-prev").click();
|
||||
await expect(page.getByText("1 / 3")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pane-find-input").focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByText("2 / 3")).toBeVisible({ timeout: 10_000 });
|
||||
await page.keyboard.press("Shift+Enter");
|
||||
await expect(page.getByText("1 / 3")).toBeVisible({ timeout: 10_000 });
|
||||
await typeFindQuery(page, "missing-value");
|
||||
await expect(page.getByText("No matches")).toBeVisible({ timeout: 10_000 });
|
||||
await typeFindQuery(page, "");
|
||||
await expect(page.getByText("0 / 0")).toBeVisible({ timeout: 10_000 });
|
||||
await typeFindQuery(page, "needle");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("pane-find-bar")).toHaveCount(0);
|
||||
await testInfo.attach("file-find-walkthrough", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await page.getByLabel("Split pane right").filter({ visible: true }).last().click();
|
||||
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
|
||||
await page.getByTestId("workspace-new-terminal").filter({ visible: true }).last().click();
|
||||
const splitTerminal = page.getByTestId("terminal-surface").last();
|
||||
await expect(splitTerminal).toBeVisible({ timeout: 20_000 });
|
||||
await splitTerminal.click();
|
||||
await setupDeterministicPrompt(page, `SPLIT_FIND_READY_${Date.now()}`);
|
||||
await splitTerminal.pressSequentially("printf 'split needle one\\nsplit needle two\\n'\n", {
|
||||
delay: 0,
|
||||
});
|
||||
await waitForTerminalContent(page, (text) => text.includes("split needle two"), 10_000);
|
||||
await openFind(page);
|
||||
await typeFindQuery(page, "needle");
|
||||
await expect(page.getByText(/[1-9] \/ [1-9]/)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.getByTestId("workspace-file-pane").click();
|
||||
await expect(page.getByTestId("pane-find-bar")).toHaveCount(0);
|
||||
await openFind(page);
|
||||
await typeFindQuery(page, "needle");
|
||||
await expect(page.getByText("1 / 3")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pane-find-close").click();
|
||||
await expect(page.getByTestId("pane-find-bar")).toHaveCount(0);
|
||||
await testInfo.attach("terminal-find-walkthrough", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
await testInfo.attach("split-pane-focus-switching", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
const agent = await agentClient.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
title: "Find pane QA",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "chat needle alpha",
|
||||
});
|
||||
await page.goto(
|
||||
`${buildHostWorkspaceRoute(getServerId(), repo.path)}?open=${encodeURIComponent(
|
||||
`agent:${agent.id}`,
|
||||
)}`,
|
||||
);
|
||||
await expect(page.getByText("chat needle alpha").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByText("chat needle alpha").first().click();
|
||||
await openFind(page);
|
||||
await typeFindQuery(page, "needle");
|
||||
await expect(page.getByText(/[1-9] \/ [1-9]/)).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pane-find-next").click();
|
||||
await page.getByTestId("pane-find-prev").click();
|
||||
await page.getByTestId("pane-find-close").click();
|
||||
await expect(page.getByTestId("pane-find-bar")).toHaveCount(0);
|
||||
await testInfo.attach("chat-find-walkthrough", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await page.getByTestId("workspace-header-menu-trigger").click();
|
||||
const browserMenuItem = page.getByTestId("workspace-header-new-browser");
|
||||
if (await browserMenuItem.isVisible().catch(() => false)) {
|
||||
await browserMenuItem.click();
|
||||
await expect(
|
||||
page.getByText("Open this workspace in Electron to use the built-in browser."),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await page.keyboard.press(findShortcut());
|
||||
await expect(page.getByTestId("pane-find-bar")).toHaveCount(0);
|
||||
await testInfo.attach("browser-web-fallback", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
} else {
|
||||
await testInfo.attach("browser-web-fallback", {
|
||||
body: "Browser tab creation is not exposed in this browser-web runtime.",
|
||||
contentType: "text/plain",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await agentClient.close();
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { spawn, type ChildProcess, execSync } from "node:child_process";
|
||||
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -7,8 +6,6 @@ import path from "node:path";
|
||||
import net from "node:net";
|
||||
import { Buffer } from "node:buffer";
|
||||
import dotenv from "dotenv";
|
||||
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
|
||||
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
|
||||
|
||||
interface WaitForServerOptions {
|
||||
@@ -191,7 +188,7 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
|
||||
let daemonProcess: ChildProcess | null = null;
|
||||
let metroProcess: ChildProcess | null = null;
|
||||
let paseoHome: string | null = null;
|
||||
let fakeEditorBinDir: string | null = null;
|
||||
let fakeToolBinDir: string | null = null;
|
||||
let relayProcess: ChildProcess | null = null;
|
||||
|
||||
function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null {
|
||||
@@ -212,24 +209,81 @@ interface OfferPayload {
|
||||
relay: { endpoint: string };
|
||||
}
|
||||
|
||||
interface DaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory: NodeWebSocketFactory;
|
||||
async function createFakeToolBin(): Promise<string> {
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-tool-bin-"));
|
||||
const ghPath = path.join(binDir, "gh");
|
||||
await writeFile(
|
||||
ghPath,
|
||||
`#!/usr/bin/env node
|
||||
const { spawnSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function findRealGh() {
|
||||
const fakeBinDir = __dirname;
|
||||
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
||||
if (dir === fakeBinDir) continue;
|
||||
const candidate = path.join(dir, "gh");
|
||||
try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface PairingDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
getDaemonPairingOffer(): Promise<{
|
||||
relayEnabled: boolean;
|
||||
url: string;
|
||||
}>;
|
||||
function forwardToRealGh() {
|
||||
const realGh = findRealGh();
|
||||
if (!realGh) { console.error("[fake-gh] real gh not found in PATH"); process.exit(1); }
|
||||
const result = spawnSync(realGh, process.argv.slice(2), { stdio: "inherit", env: process.env });
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
async function createFakeEditorBin(): Promise<string> {
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
|
||||
if (args[0] === "auth" && args[1] === "status") {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args[0] === "pr" && args[1] === "list") {
|
||||
console.log(JSON.stringify([
|
||||
{
|
||||
number: 515,
|
||||
title: "Review selected start ref",
|
||||
url: "https://github.com/getpaseo/paseo/pull/515",
|
||||
state: "OPEN",
|
||||
body: "Fixture pull request for app e2e.",
|
||||
labels: [],
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/start-from-pr"
|
||||
}
|
||||
]));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args[0] === "pr" && args[1] === "view" && args[2] === "--json" && args[3]) {
|
||||
const fixture = path.join(process.cwd(), ".paseo-e2e-pr.json");
|
||||
if (fs.existsSync(fixture)) {
|
||||
console.log(fs.readFileSync(fixture, "utf8"));
|
||||
process.exit(0);
|
||||
}
|
||||
forwardToRealGh();
|
||||
}
|
||||
|
||||
if (args[0] === "api" && args[1] === "graphql") {
|
||||
const fixture = path.join(process.cwd(), ".paseo-e2e-timeline.json");
|
||||
if (fs.existsSync(fixture)) {
|
||||
console.log(fs.readFileSync(fixture, "utf8"));
|
||||
process.exit(0);
|
||||
}
|
||||
forwardToRealGh();
|
||||
}
|
||||
|
||||
if (args[0] === "issue" && args[1] === "list") {
|
||||
console.log("[]");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
forwardToRealGh();
|
||||
`,
|
||||
);
|
||||
await chmod(ghPath, 0o755);
|
||||
|
||||
const fakeEditorSource = `#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
@@ -289,29 +343,29 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
||||
return offer as OfferPayload;
|
||||
}
|
||||
|
||||
async function loadPairingOfferFromDaemon(port: number): Promise<OfferPayload> {
|
||||
const DaemonClient = await loadDaemonClientConstructor<DaemonClientConfig, PairingDaemonClient>();
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${port}/ws`,
|
||||
clientId: `playwright-global-setup-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
webSocketFactory: createNodeWebSocketFactory(),
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
try {
|
||||
const pairing = await client.getDaemonPairingOffer();
|
||||
if (!pairing.relayEnabled || !pairing.url) {
|
||||
throw new Error("Daemon returned a disabled pairing offer");
|
||||
}
|
||||
return decodeOfferFromFragmentUrl(pairing.url);
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
function loadPairingOfferFromCli(repoRoot: string, paseoHomePath: string): OfferPayload {
|
||||
const stdout = execFileSync(
|
||||
process.execPath,
|
||||
["--import", "tsx", "packages/cli/src/index.ts", "daemon", "pair", "--json"],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHomePath,
|
||||
},
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
const payload = JSON.parse(stdout) as { relayEnabled?: boolean; url?: string | null };
|
||||
if (payload.relayEnabled !== true || typeof payload.url !== "string") {
|
||||
throw new Error(`Unexpected daemon pair response: ${stdout}`);
|
||||
}
|
||||
return decodeOfferFromFragmentUrl(payload.url);
|
||||
}
|
||||
|
||||
async function waitForPairingOfferFromDaemon(args: {
|
||||
port: number;
|
||||
async function waitForPairingOfferFromCli(args: {
|
||||
repoRoot: string;
|
||||
paseoHome: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<OfferPayload> {
|
||||
const timeoutMs = args.timeoutMs ?? 15000;
|
||||
@@ -320,7 +374,7 @@ async function waitForPairingOfferFromDaemon(args: {
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
return await loadPairingOfferFromDaemon(args.port);
|
||||
return loadPairingOfferFromCli(args.repoRoot, args.paseoHome);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await sleep(100);
|
||||
@@ -328,7 +382,7 @@ async function waitForPairingOfferFromDaemon(args: {
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out waiting for daemon pairing offer: ${
|
||||
`Timed out waiting for \`paseo daemon pair --json\` to produce a pairing offer: ${
|
||||
lastError instanceof Error ? lastError.message : String(lastError)
|
||||
}`,
|
||||
);
|
||||
@@ -575,7 +629,7 @@ interface DaemonSpawnArgs {
|
||||
relayPort: number;
|
||||
metroPort: number;
|
||||
paseoHome: string;
|
||||
fakeEditorBinDir: string;
|
||||
fakeToolBinDir: string;
|
||||
editorRecordPath: string;
|
||||
dictation: DictationConfig;
|
||||
buffer: ReturnType<typeof createLineBuffer>;
|
||||
@@ -590,7 +644,7 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${args.fakeEditorBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PATH: `${args.fakeToolBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
@@ -656,9 +710,9 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
|
||||
} else if (paseoHome) {
|
||||
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
|
||||
}
|
||||
if (fakeEditorBinDir) {
|
||||
await rm(fakeEditorBinDir, { recursive: true, force: true });
|
||||
fakeEditorBinDir = null;
|
||||
if (fakeToolBinDir) {
|
||||
await rm(fakeToolBinDir, { recursive: true, force: true });
|
||||
fakeToolBinDir = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,7 +727,7 @@ export default async function globalSetup() {
|
||||
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
|
||||
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));
|
||||
const editorRecordPath = path.join(paseoHome, "editor-open-records.jsonl");
|
||||
fakeEditorBinDir = await createFakeEditorBin();
|
||||
fakeToolBinDir = await createFakeToolBin();
|
||||
const metroLineBuffer = createLineBuffer();
|
||||
const daemonLineBuffer = createLineBuffer();
|
||||
|
||||
@@ -691,7 +745,7 @@ export default async function globalSetup() {
|
||||
relayPort,
|
||||
metroPort,
|
||||
paseoHome,
|
||||
fakeEditorBinDir,
|
||||
fakeToolBinDir,
|
||||
editorRecordPath,
|
||||
dictation,
|
||||
buffer: daemonLineBuffer,
|
||||
@@ -711,8 +765,9 @@ export default async function globalSetup() {
|
||||
}),
|
||||
]);
|
||||
|
||||
const offer = await waitForPairingOfferFromDaemon({
|
||||
port,
|
||||
const offer = await waitForPairingOfferFromCli({
|
||||
repoRoot,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
process.env.E2E_DAEMON_PORT = String(port);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { appendFile } from "node:fs/promises";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { openSettings } from "./app";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
@@ -65,21 +64,6 @@ export interface DesktopBridgeConfig {
|
||||
* false so tests that only assert copy don't inadvertently trigger state changes.
|
||||
*/
|
||||
confirmShouldAccept?: boolean;
|
||||
editorTargets?: DesktopEditorTargetConfig[];
|
||||
editorRecordPath?: string;
|
||||
}
|
||||
|
||||
interface DesktopEditorTargetConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: "editor" | "file-manager";
|
||||
}
|
||||
|
||||
interface DesktopEditorOpenRecord {
|
||||
editorId: string;
|
||||
path: string;
|
||||
cwd?: string;
|
||||
mode?: "open" | "reveal";
|
||||
}
|
||||
|
||||
export interface ConfirmDialogCall {
|
||||
@@ -90,7 +74,6 @@ export interface ConfirmDialogCall {
|
||||
declare global {
|
||||
interface Window {
|
||||
__capturedDialogCall: ConfirmDialogCall | undefined;
|
||||
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,15 +87,6 @@ declare global {
|
||||
* can assert dialog copy without depending on window.confirm concatenation.
|
||||
*/
|
||||
export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfig): Promise<void> {
|
||||
if (config.editorRecordPath) {
|
||||
await page.exposeFunction(
|
||||
"__recordDesktopEditorOpen",
|
||||
async (input: DesktopEditorOpenRecord) => {
|
||||
await appendFile(config.editorRecordPath as string, `${JSON.stringify(input)}\n`, "utf8");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await page.addInitScript((cfg) => {
|
||||
// Mutable state shared across IPC calls within this page
|
||||
let manageDaemon = cfg.manageBuiltInDaemon ?? false;
|
||||
@@ -124,7 +98,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
return {
|
||||
serverId: cfg.serverId,
|
||||
status: daemonRunning ? "running" : "stopped",
|
||||
listen: "127.0.0.1:6767",
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: currentPid,
|
||||
home: "",
|
||||
@@ -134,19 +108,7 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
};
|
||||
}
|
||||
|
||||
const desktopBridge: {
|
||||
platform: string;
|
||||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
dialog: {
|
||||
ask: (message: string, options?: Record<string, unknown>) => Promise<boolean>;
|
||||
};
|
||||
getPendingOpenProject: () => Promise<string | null>;
|
||||
events: { on: () => Promise<() => void> };
|
||||
editor?: {
|
||||
listTargets: () => Promise<DesktopEditorTargetConfig[]>;
|
||||
openTarget: (input: DesktopEditorOpenRecord) => Promise<void>;
|
||||
};
|
||||
} = {
|
||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
||||
platform: "darwin",
|
||||
invoke: async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "check_app_update") {
|
||||
@@ -240,26 +202,15 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
getPendingOpenProject: async () => null,
|
||||
events: { on: async () => () => undefined },
|
||||
};
|
||||
|
||||
if (cfg.editorTargets) {
|
||||
desktopBridge.editor = {
|
||||
listTargets: async () => cfg.editorTargets ?? [],
|
||||
openTarget: async (input: DesktopEditorOpenRecord) => {
|
||||
await window.__recordDesktopEditorOpen?.(input);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = desktopBridge;
|
||||
}, config);
|
||||
}
|
||||
|
||||
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
// The daemon-lifecycle card moved to the Host section in the flat-settings
|
||||
// The daemon-lifecycle card moved to the Daemon section in the flat-settings
|
||||
// layout; navigate there before asserting it.
|
||||
await openSettingsHostSection(page, serverId, "host");
|
||||
await openSettingsHostSection(page, serverId, "daemon");
|
||||
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
@@ -124,43 +124,19 @@ export async function openNewWorkspaceComposer(
|
||||
});
|
||||
}
|
||||
|
||||
export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-new-workspace").click();
|
||||
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectNewWorkspaceProjectSelected(
|
||||
page: Page,
|
||||
projectDisplayName: string,
|
||||
): Promise<void> {
|
||||
const projectPicker = page.getByRole("button", { name: "Workspace project" });
|
||||
await expect(projectPicker).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectPicker).toContainText(projectDisplayName);
|
||||
}
|
||||
|
||||
export async function submitNewWorkspacePrompt(
|
||||
page: Page,
|
||||
prompt = "Hello from e2e",
|
||||
): Promise<void> {
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||
await composer.fill(prompt);
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||
await createButton.click();
|
||||
}
|
||||
|
||||
export async function clickNewWorkspaceButton(
|
||||
page: Page,
|
||||
input: { projectKey: string; projectDisplayName: string; prompt?: string },
|
||||
): Promise<void> {
|
||||
await openNewWorkspaceComposer(page, input);
|
||||
await submitNewWorkspacePrompt(page, input.prompt);
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||
await composer.fill(input.prompt ?? "Hello from e2e");
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||
await createButton.click();
|
||||
}
|
||||
|
||||
export async function openStartingRefPicker(page: Page): Promise<void> {
|
||||
@@ -201,9 +177,7 @@ export async function selectPickerOptionByKeyboard(page: Page, label: string): P
|
||||
const searchInput = page.getByPlaceholder("Search branches and PRs");
|
||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||
await page.keyboard.type(label);
|
||||
await expect(page.getByTestId(`new-workspace-ref-picker-branch-${label}`)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
|
||||
@@ -238,13 +212,7 @@ export async function expectComposerGithubAttachmentPill(
|
||||
|
||||
export async function assertNewWorkspaceSidebarAndHeader(
|
||||
page: Page,
|
||||
input: {
|
||||
serverId: string;
|
||||
previousWorkspaceId: string;
|
||||
projectDisplayName: string;
|
||||
assertSidebarRow?: boolean;
|
||||
assertHeader?: boolean;
|
||||
},
|
||||
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },
|
||||
): Promise<{ workspaceId: string }> {
|
||||
// Wait for URL to redirect to the newly created workspace.
|
||||
// Uses URL as source of truth to avoid picking up sidebar rows from concurrent tests.
|
||||
@@ -262,19 +230,15 @@ export async function assertNewWorkspaceSidebarAndHeader(
|
||||
throw new Error(`Expected URL to redirect to a new workspace.\nCurrent URL: ${page.url()}`);
|
||||
}
|
||||
|
||||
if (input.assertSidebarRow !== false) {
|
||||
const createdWorkspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${input.serverId}:${workspaceId}`,
|
||||
);
|
||||
await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
const createdWorkspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${input.serverId}:${workspaceId}`,
|
||||
);
|
||||
await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
if (input.assertHeader !== false) {
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspaceLabelFromPath(workspaceId),
|
||||
subtitle: input.projectDisplayName,
|
||||
});
|
||||
}
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspaceLabelFromPath(workspaceId),
|
||||
subtitle: input.projectDisplayName,
|
||||
});
|
||||
|
||||
return { workspaceId };
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export async function waitForQuestionPrompt(page: Page, timeout = 30_000): Promise<void> {
|
||||
await expect(page.getByTestId("question-form-card").first()).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
export async function expectCurrentQuestion(
|
||||
page: Page,
|
||||
input: { index: number; total: number; question: string },
|
||||
): Promise<void> {
|
||||
const card = page.getByTestId("question-form-card").first();
|
||||
await expect(card.getByTestId("question-form-current-question")).toHaveText(input.question);
|
||||
await expect(
|
||||
card.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
export async function expectQuestionHidden(page: Page, question: string): Promise<void> {
|
||||
await expect(page.getByText(question, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
|
||||
await page
|
||||
.getByTestId("question-form-card")
|
||||
.first()
|
||||
.getByRole("button", { name: option })
|
||||
.click();
|
||||
}
|
||||
|
||||
export async function expectQuestionOptionSelected(page: Page, option: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("question-form-card").first().getByRole("button", { name: option }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
export async function openQuestion(
|
||||
page: Page,
|
||||
input: { index: number; total: number },
|
||||
): Promise<void> {
|
||||
await page
|
||||
.getByTestId("question-form-card")
|
||||
.first()
|
||||
.getByRole("button", { name: `Question ${input.index} of ${input.total}` })
|
||||
.click();
|
||||
}
|
||||
|
||||
export async function fillQuestionAnswer(
|
||||
page: Page,
|
||||
input: { question: string; answer: string },
|
||||
): Promise<void> {
|
||||
await page
|
||||
.getByTestId("question-form-card")
|
||||
.first()
|
||||
.getByRole("textbox", { name: input.question })
|
||||
.fill(input.answer);
|
||||
}
|
||||
|
||||
export async function submitQuestionAnswers(page: Page): Promise<void> {
|
||||
await page.getByTestId("question-form-primary-action").click();
|
||||
await expect(page.getByTestId("question-form-card")).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
@@ -1,21 +1,10 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost, TEST_HOST_LABEL } from "./daemon-registry";
|
||||
import { TEST_HOST_LABEL } from "./daemon-registry";
|
||||
import { escapeRegex } from "./regex";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||
const REGISTRY_KEY = "@paseo:daemon-registry";
|
||||
|
||||
interface SavedSettingsHostInput {
|
||||
serverId: string;
|
||||
label: string;
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
const SECTION_LABELS = {
|
||||
general: "General",
|
||||
appearance: "Appearance",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
permissions: "Permissions",
|
||||
@@ -25,7 +14,7 @@ const SECTION_LABELS = {
|
||||
|
||||
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
|
||||
|
||||
type HostSection = "connections" | "agents" | "workspaces" | "providers" | "host";
|
||||
type HostSection = "connections" | "orchestration" | "providers" | "daemon";
|
||||
|
||||
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
@@ -94,60 +83,6 @@ export async function openCompactSettings(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function seedSavedSettingsHosts(
|
||||
page: Page,
|
||||
hosts: SavedSettingsHostInput[],
|
||||
): Promise<void> {
|
||||
await page.goto("/");
|
||||
const nowIso = new Date().toISOString();
|
||||
const registry = hosts.map((host) =>
|
||||
buildSeededHost({
|
||||
serverId: host.serverId,
|
||||
label: host.label,
|
||||
endpoint: host.endpoint,
|
||||
nowIso,
|
||||
}),
|
||||
);
|
||||
const firstHost = registry[0];
|
||||
if (!firstHost) {
|
||||
throw new Error("Expected at least one settings host fixture.");
|
||||
}
|
||||
const preferences = buildCreateAgentPreferences(firstHost.serverId);
|
||||
|
||||
await page.evaluate(
|
||||
({ keys, storedRegistry, storedPreferences }) => {
|
||||
const nonce = localStorage.getItem(keys.seedNonce);
|
||||
if (!nonce) {
|
||||
throw new Error("Expected e2e seed nonce before overriding settings host registry.");
|
||||
}
|
||||
|
||||
localStorage.setItem(keys.registry, JSON.stringify(storedRegistry));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(storedPreferences));
|
||||
localStorage.setItem(keys.disableDefaultSeedOnce, nonce);
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
disableDefaultSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
registry: REGISTRY_KEY,
|
||||
seedNonce: SEED_NONCE_KEY,
|
||||
},
|
||||
storedRegistry: registry,
|
||||
storedPreferences: preferences,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function selectSettingsHost(page: Page, serverId: string): Promise<void> {
|
||||
await page.getByTestId("settings-host-picker").click();
|
||||
await page.getByTestId(`settings-host-picker-item-${serverId}`).click();
|
||||
}
|
||||
|
||||
export async function expectSettingsHostPickerLabel(page: Page, label: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("settings-host-picker").getByText(label, { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectCompactSettingsList(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
@@ -245,11 +180,7 @@ export async function expectAboutContent(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
export async function expectGeneralContent(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Default send", { exact: true }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectAppearanceContent(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Highlight theme", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostLabelDisplayed(page: Page): Promise<void> {
|
||||
@@ -295,9 +226,9 @@ export async function openHostSection(
|
||||
}
|
||||
|
||||
export async function expectHostActionCards(page: Page, serverId: string): Promise<void> {
|
||||
// Restart + remove cards live on the Host section; providers moved to its
|
||||
// Restart + remove cards live on the Daemon section; providers moved to its
|
||||
// own Providers section (asserted via expectHostProvidersCard).
|
||||
await openSettingsHostSection(page, serverId, "host");
|
||||
await openSettingsHostSection(page, serverId, "daemon");
|
||||
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
||||
@@ -360,10 +291,9 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<vo
|
||||
|
||||
// Host group rows are now flat top-level sections (no drill-in).
|
||||
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-agents")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-workspaces")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-orchestration")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-providers")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-host")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-daemon")).toBeVisible();
|
||||
|
||||
// The old per-host entry rows are replaced by the host picker.
|
||||
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]')).toHaveCount(0);
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
|
||||
|
||||
interface LongTimelineAgentOptions {
|
||||
turns: number;
|
||||
}
|
||||
|
||||
interface LongTimelineAgent extends MockAgentWorkspace {
|
||||
oldestPrompt: string;
|
||||
newestPrompt: string;
|
||||
}
|
||||
|
||||
const PROMPT_PREFIX = "timeline-pagination-turn";
|
||||
|
||||
function promptForTurn(index: number): string {
|
||||
return `${PROMPT_PREFIX}-${index}: emit 1 coalesced agent stream updates`;
|
||||
}
|
||||
|
||||
export async function seedLongMockAgentTimeline(
|
||||
options: LongTimelineAgentOptions,
|
||||
): Promise<LongTimelineAgent> {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "timeline-pagination-",
|
||||
title: "Timeline pagination regression",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
|
||||
for (let index = 0; index < options.turns; index += 1) {
|
||||
await agent.client.sendAgentMessage(agent.agentId, promptForTurn(index));
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
}
|
||||
|
||||
return {
|
||||
...agent,
|
||||
oldestPrompt: promptForTurn(0),
|
||||
newestPrompt: promptForTurn(options.turns - 1),
|
||||
};
|
||||
}
|
||||
|
||||
export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): Promise<void> {
|
||||
await page.goto(buildAgentRoute(agent.cwd, agent.agentId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectTimelinePromptVisible(page: Page, prompt: string): Promise<void> {
|
||||
await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectTimelinePromptNotMounted(page: Page, prompt: string): Promise<void> {
|
||||
await expect(page.getByText(prompt, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await scroll.hover();
|
||||
await page.mouse.wheel(0, -20_000);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
await scroll.evaluate((element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error("Agent chat scroll element is not an HTMLElement");
|
||||
}
|
||||
element.scrollTop = 0;
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
export async function scrollTimelineUntilOlderHistoryIsReachable(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const previousHeight = await scroll.evaluate((element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error("Agent chat scroll element is not an HTMLElement");
|
||||
}
|
||||
return element.scrollHeight;
|
||||
});
|
||||
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
}),
|
||||
);
|
||||
await scrollTimelineToOldestLoadedEdge(page);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
scroll.evaluate((element) => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error("Agent chat scroll element is not an HTMLElement");
|
||||
}
|
||||
return element.scrollHeight;
|
||||
}),
|
||||
)
|
||||
.toBeGreaterThan(previousHeight);
|
||||
await scrollTimelineToOldestLoadedEdge(page);
|
||||
}
|
||||
@@ -7,17 +7,16 @@ import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
assertNewWorkspaceSidebarAndHeader,
|
||||
clickNewWorkspaceButton,
|
||||
closeBranchPicker,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
createWorktreeViaDaemon,
|
||||
delayBrowserAgentCreatedStatus,
|
||||
expectComposerGithubAttachmentPill,
|
||||
expectNewWorkspaceProjectSelected,
|
||||
expectPickerClosed,
|
||||
expectPickerOpen,
|
||||
expectPickerSelected,
|
||||
expectStartingRefPickerTriggerPr,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openBranchPicker,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
@@ -25,10 +24,8 @@ import {
|
||||
selectBranchInPicker,
|
||||
selectGitHubPrInPicker,
|
||||
selectPickerOptionByKeyboard,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "./helpers/new-workspace";
|
||||
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
||||
import { createTempGithubRepo, hasGithubAuth } from "./helpers/github-fixtures";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
@@ -39,147 +36,6 @@ import {
|
||||
workspaceLabelFromPath,
|
||||
} from "./helpers/workspace-ui";
|
||||
|
||||
interface WorkspaceStatusGroupEvent {
|
||||
rowTestId: string;
|
||||
bucket: string;
|
||||
indicatorTestId: string | null;
|
||||
label: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("sidebar-grouping-selector").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await expect(page.getByTestId("sidebar-status-group-done")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function startTrackingSidebarStatusGroups(page: import("@playwright/test").Page) {
|
||||
await page.evaluate(() => {
|
||||
interface StatusGroupEvent {
|
||||
rowTestId: string;
|
||||
bucket: string;
|
||||
indicatorTestId: string | null;
|
||||
label: string;
|
||||
at: number;
|
||||
}
|
||||
const win = window as typeof window & {
|
||||
__workspaceStatusGroupEvents?: StatusGroupEvent[];
|
||||
__workspaceStatusGroupObserver?: MutationObserver;
|
||||
};
|
||||
win.__workspaceStatusGroupEvents = [];
|
||||
win.__workspaceStatusGroupObserver?.disconnect();
|
||||
|
||||
const capture = () => {
|
||||
const events = win.__workspaceStatusGroupEvents;
|
||||
if (!events) return;
|
||||
const groups = document.querySelectorAll<HTMLElement>(
|
||||
'[data-testid^="sidebar-status-group-"]',
|
||||
);
|
||||
for (const group of groups) {
|
||||
const groupTestId = group.getAttribute("data-testid") ?? "";
|
||||
const bucket = groupTestId.replace("sidebar-status-group-", "");
|
||||
const label = group.textContent ?? "";
|
||||
const block = group.parentElement?.parentElement;
|
||||
if (!block) continue;
|
||||
const rows = block.querySelectorAll<HTMLElement>('[data-testid^="sidebar-workspace-row-"]');
|
||||
for (const row of rows) {
|
||||
const rowTestId = row.getAttribute("data-testid");
|
||||
if (!rowTestId) continue;
|
||||
const indicatorTestId =
|
||||
row
|
||||
.querySelector<HTMLElement>('[data-testid^="workspace-status-indicator-"]')
|
||||
?.getAttribute("data-testid") ?? null;
|
||||
const last = events.at(-1);
|
||||
if (
|
||||
last?.rowTestId === rowTestId &&
|
||||
last.bucket === bucket &&
|
||||
last.indicatorTestId === indicatorTestId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
events.push({ rowTestId, bucket, indicatorTestId, label, at: performance.now() });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
capture();
|
||||
const observer = new MutationObserver(capture);
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
||||
win.__workspaceStatusGroupObserver = observer;
|
||||
});
|
||||
}
|
||||
|
||||
async function getTrackedSidebarStatusGroups(
|
||||
page: import("@playwright/test").Page,
|
||||
): Promise<WorkspaceStatusGroupEvent[]> {
|
||||
return page.evaluate(() => {
|
||||
const win = window as typeof window & {
|
||||
__workspaceStatusGroupEvents?: WorkspaceStatusGroupEvent[];
|
||||
};
|
||||
return win.__workspaceStatusGroupEvents ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForWorkspaceStatusGroupEvent(input: {
|
||||
page: import("@playwright/test").Page;
|
||||
rowTestId: string;
|
||||
bucket: string;
|
||||
}) {
|
||||
await input.page.waitForFunction(
|
||||
({ expectedRowTestId, expectedBucket }) => {
|
||||
const win = window as typeof window & {
|
||||
__workspaceStatusGroupEvents?: WorkspaceStatusGroupEvent[];
|
||||
};
|
||||
for (const event of win.__workspaceStatusGroupEvents ?? []) {
|
||||
if (event.rowTestId === expectedRowTestId && event.bucket === expectedBucket) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ expectedRowTestId: input.rowTestId, expectedBucket: input.bucket },
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
}
|
||||
|
||||
async function expectWorkspaceStatusGroupEvents(input: {
|
||||
page: import("@playwright/test").Page;
|
||||
rowTestId: string;
|
||||
includes: string;
|
||||
excludes: string;
|
||||
includesIndicator?: string;
|
||||
excludesIndicator?: string;
|
||||
}) {
|
||||
await waitForWorkspaceStatusGroupEvent({
|
||||
page: input.page,
|
||||
rowTestId: input.rowTestId,
|
||||
bucket: input.includes,
|
||||
});
|
||||
const createdWorkspaceEvents = (await getTrackedSidebarStatusGroups(input.page)).filter(
|
||||
(event) => event.rowTestId === input.rowTestId,
|
||||
);
|
||||
expect(createdWorkspaceEvents.map((event) => event.bucket)).toContain(input.includes);
|
||||
expect(createdWorkspaceEvents.filter((event) => event.bucket === input.excludes)).toEqual([]);
|
||||
if (input.includesIndicator) {
|
||||
expect(createdWorkspaceEvents.map((event) => event.indicatorTestId)).toContain(
|
||||
input.includesIndicator,
|
||||
);
|
||||
}
|
||||
if (input.excludesIndicator) {
|
||||
expect(
|
||||
createdWorkspaceEvents.filter((event) => event.indicatorTestId === input.excludesIndicator),
|
||||
).toEqual([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitNewWorkspaceWithoutPrompt(page: import("@playwright/test").Page) {
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||
await createButton.click();
|
||||
}
|
||||
|
||||
test.describe("New workspace flow", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
@@ -337,7 +193,7 @@ test.describe("New workspace flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("global new workspace uses the last active project and creates one agent tab", async ({
|
||||
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one agent tab", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
@@ -361,16 +217,15 @@ test.describe("New workspace flow", () => {
|
||||
subtitle: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, openedProject.projectDisplayName);
|
||||
await submitNewWorkspacePrompt(page);
|
||||
await clickNewWorkspaceButton(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
assertSidebarRow: false,
|
||||
assertHeader: false,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
|
||||
@@ -457,8 +312,6 @@ test.describe("New workspace flow", () => {
|
||||
serverId,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
assertSidebarRow: false,
|
||||
assertHeader: false,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
|
||||
@@ -490,117 +343,6 @@ test.describe("New workspace flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("new workspace with initial agent never appears in the Done status group", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
const tempRepo = await createTempGitRepo("new-workspace-status-optimistic-");
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: openedProject.workspaceName,
|
||||
subtitle: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
await switchSidebarToStatusGrouping(page);
|
||||
await startTrackingSidebarStatusGroups(page);
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, openedProject.projectDisplayName);
|
||||
await submitNewWorkspacePrompt(page);
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
assertSidebarRow: false,
|
||||
assertHeader: false,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
|
||||
const rowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
|
||||
await expectWorkspaceStatusGroupEvents({
|
||||
page,
|
||||
rowTestId,
|
||||
includes: "running",
|
||||
excludes: "done",
|
||||
includesIndicator: "workspace-status-indicator-running",
|
||||
});
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("new workspace without an initial agent appears in the Done status group", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
const tempRepo = await createTempGitRepo("new-workspace-status-empty-");
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: openedProject.workspaceName,
|
||||
subtitle: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
await switchSidebarToStatusGrouping(page);
|
||||
await startTrackingSidebarStatusGroups(page);
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, openedProject.projectDisplayName);
|
||||
await submitNewWorkspaceWithoutPrompt(page);
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
|
||||
const rowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
|
||||
await expectWorkspaceStatusGroupEvents({
|
||||
page,
|
||||
rowTestId,
|
||||
includes: "done",
|
||||
excludes: "running",
|
||||
excludesIndicator: "workspace-status-indicator-loading",
|
||||
});
|
||||
await expectWorkspaceStatusGroupEvents({
|
||||
page,
|
||||
rowTestId,
|
||||
includes: "done",
|
||||
excludes: "running",
|
||||
excludesIndicator: "workspace-status-indicator-running",
|
||||
});
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -658,7 +400,7 @@ test.describe("New workspace flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("branch picker opens via keyboard and selects the filtered option on Enter", async ({
|
||||
test("branch picker opens via keyboard, navigates options, and selects on Enter", async ({
|
||||
page,
|
||||
}) => {
|
||||
const tempRepo = await createTempGitRepo("picker-keyboard-", { branches: ["main", "dev"] });
|
||||
@@ -708,16 +450,10 @@ test.describe("New workspace flow", () => {
|
||||
});
|
||||
|
||||
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
|
||||
test.skip(!hasGithubAuth(), "Requires GitHub authentication (gh auth login)");
|
||||
|
||||
const ghRepo = await createTempGithubRepo({
|
||||
category: "new-workspace-pr-ref",
|
||||
prs: [{ title: "Review selected start ref", state: "open" }],
|
||||
});
|
||||
const pr = ghRepo.prs[0]!;
|
||||
const tempRepo = await createTempGitRepo("new-workspace-pr-ref-");
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, pr.localPath);
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
@@ -727,19 +463,19 @@ test.describe("New workspace flow", () => {
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await openStartingRefPicker(page);
|
||||
await selectGitHubPrInPicker(page, pr.number);
|
||||
await selectGitHubPrInPicker(page, 515);
|
||||
|
||||
await expectStartingRefPickerTriggerPr(page, {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
headRef: pr.branch,
|
||||
number: 515,
|
||||
title: "Review selected start ref",
|
||||
headRef: "feature/start-from-pr",
|
||||
});
|
||||
await expectComposerGithubAttachmentPill(page, {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
number: 515,
|
||||
title: "Review selected start ref",
|
||||
});
|
||||
} finally {
|
||||
await ghRepo.cleanup();
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
|
||||
const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
|
||||
async function openMockAgentAtMobileBreakpoint(page: Page) {
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "provider-sheet-stack-",
|
||||
title: "Provider sheet stack e2e",
|
||||
initialPrompt: "Prepare provider sheet stack test agent.",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await expect(page.getByRole("button", { name: /Select model/ })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
async function openProviderSettingsFromModelSelector(page: Page) {
|
||||
await page.getByRole("button", { name: /Select model/ }).click();
|
||||
await expect(page.getByLabel("Bottom Sheet", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const openCodeRow = page.getByText("OpenCode", { exact: true }).first();
|
||||
if (await openCodeRow.isVisible().catch(() => false)) {
|
||||
await openCodeRow.click();
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: /Open .* settings/ }).click();
|
||||
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function expectModelSelectorVisible(page: Page) {
|
||||
await expect(page.getByRole("button", { name: /Open .* settings/ })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function closeTopSheet(page: Page) {
|
||||
const closeTarget = page.getByLabel("Close", { exact: true }).last();
|
||||
if (await closeTarget.isVisible().catch(() => false)) {
|
||||
await closeTarget.click({ force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const handle = page.getByRole("slider", { name: "Bottom sheet handle" }).last();
|
||||
const handleBox = await handle.boundingBox();
|
||||
if (!handleBox) {
|
||||
throw new Error("Bottom sheet handle was not measurable");
|
||||
}
|
||||
const startX = handleBox.x + handleBox.width / 2;
|
||||
const startY = handleBox.y + handleBox.height / 2;
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(startX, startY + 400, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function expectProviderSettingsVisible(page: Page) {
|
||||
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("button", { name: "Add model" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Diagnostic", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function exerciseProviderSettingsStack(page: Page) {
|
||||
await expectProviderSettingsVisible(page);
|
||||
|
||||
await page.getByRole("button", { name: "Add model" }).click();
|
||||
await expect(page.getByTestId("add-custom-model-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await closeTopSheet(page);
|
||||
await expectProviderSettingsVisible(page);
|
||||
|
||||
await page.getByRole("button", { name: "Diagnostic", exact: true }).click();
|
||||
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole("button", { name: /Refresh diagnostic/ }).click();
|
||||
await expect(page.getByTestId("provider-diagnostic-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await closeTopSheet(page);
|
||||
await expectProviderSettingsVisible(page);
|
||||
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await expectProviderSettingsVisible(page);
|
||||
}
|
||||
|
||||
test.describe("provider settings bottom-sheet stack", () => {
|
||||
test("provider settings and children close back through the model selector stack", async ({
|
||||
page,
|
||||
}) => {
|
||||
const session = await openMockAgentAtMobileBreakpoint(page);
|
||||
|
||||
try {
|
||||
await openProviderSettingsFromModelSelector(page);
|
||||
await exerciseProviderSettingsStack(page);
|
||||
await closeTopSheet(page);
|
||||
|
||||
await expectModelSelectorVisible(page);
|
||||
await page.getByRole("button", { name: /Open .* settings/ }).click();
|
||||
await expect(page.getByTestId("provider-settings-sheet")).toBeVisible({ timeout: 10_000 });
|
||||
await exerciseProviderSettingsStack(page);
|
||||
await closeTopSheet(page);
|
||||
|
||||
await expectModelSelectorVisible(page);
|
||||
await closeTopSheet(page);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import { test } from "./fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
import {
|
||||
chooseQuestionOption,
|
||||
expectCurrentQuestion,
|
||||
expectQuestionHidden,
|
||||
expectQuestionOptionSelected,
|
||||
fillQuestionAnswer,
|
||||
openQuestion,
|
||||
submitQuestionAnswers,
|
||||
waitForQuestionPrompt,
|
||||
} from "./helpers/questions";
|
||||
|
||||
const TOTAL_QUESTIONS = 3;
|
||||
const SURFACE_QUESTION = "Which surface should this apply to?";
|
||||
const ROLLOUT_QUESTION = "Which rollout should we use?";
|
||||
const SUCCESS_QUESTION = "What success criteria should we use?";
|
||||
|
||||
test.describe("Question prompt pagination", () => {
|
||||
test("shows one question at a time with numbered navigation", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "question-pagination-",
|
||||
title: "Question pagination e2e",
|
||||
initialPrompt: "Emit synthetic questions.",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await waitForQuestionPrompt(page, 120_000);
|
||||
|
||||
await expectCurrentQuestion(page, {
|
||||
index: 1,
|
||||
total: TOTAL_QUESTIONS,
|
||||
question: SURFACE_QUESTION,
|
||||
});
|
||||
await expectQuestionHidden(page, ROLLOUT_QUESTION);
|
||||
await expectQuestionHidden(page, SUCCESS_QUESTION);
|
||||
|
||||
await chooseQuestionOption(page, "App");
|
||||
await expectCurrentQuestion(page, {
|
||||
index: 2,
|
||||
total: TOTAL_QUESTIONS,
|
||||
question: ROLLOUT_QUESTION,
|
||||
});
|
||||
|
||||
await openQuestion(page, { index: 1, total: TOTAL_QUESTIONS });
|
||||
await expectCurrentQuestion(page, {
|
||||
index: 1,
|
||||
total: TOTAL_QUESTIONS,
|
||||
question: SURFACE_QUESTION,
|
||||
});
|
||||
await expectQuestionOptionSelected(page, "App");
|
||||
|
||||
await openQuestion(page, { index: 2, total: TOTAL_QUESTIONS });
|
||||
await chooseQuestionOption(page, "Behind feature flag");
|
||||
await expectCurrentQuestion(page, {
|
||||
index: 3,
|
||||
total: TOTAL_QUESTIONS,
|
||||
question: SUCCESS_QUESTION,
|
||||
});
|
||||
|
||||
await fillQuestionAnswer(page, {
|
||||
question: SUCCESS_QUESTION,
|
||||
answer: "Only one prompt is visible at a time.",
|
||||
});
|
||||
await submitQuestionAnswers(page);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -33,15 +33,15 @@ test.describe("Settings host page", () => {
|
||||
await expectHostConnectionsCard(page, port);
|
||||
});
|
||||
|
||||
test("agents section shows the inject MCP toggle", async ({ page }) => {
|
||||
test("orchestration section shows the inject MCP toggle", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
await openHostSection(page, serverId, "agents");
|
||||
await expectSettingsHeader(page, "Agents");
|
||||
await openHostSection(page, serverId, "orchestration");
|
||||
await expectSettingsHeader(page, "Orchestration");
|
||||
await expectHostInjectMcpCard(page);
|
||||
});
|
||||
|
||||
@@ -56,15 +56,15 @@ test.describe("Settings host page", () => {
|
||||
await expectSettingsHeader(page, "Providers");
|
||||
});
|
||||
|
||||
test("host section shows the host label and restart/remove action cards", async ({ page }) => {
|
||||
test("daemon section shows the host label and restart/remove action cards", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
await openHostSection(page, serverId, "host");
|
||||
await expectSettingsHeader(page, "Host");
|
||||
await openHostSection(page, serverId, "daemon");
|
||||
await expectSettingsHeader(page, "Daemon");
|
||||
await expectHostLabelDisplayed(page);
|
||||
await expectHostActionCards(page, serverId);
|
||||
});
|
||||
@@ -75,14 +75,14 @@ test.describe("Settings host page", () => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "host");
|
||||
await openHostSection(page, serverId, "daemon");
|
||||
|
||||
await expectHostLabelDisplayed(page);
|
||||
await clickEditHostLabel(page);
|
||||
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
|
||||
});
|
||||
|
||||
test("host section does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
|
||||
test("daemon section does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
@@ -90,7 +90,7 @@ test.describe("Settings host page", () => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "host");
|
||||
await openHostSection(page, serverId, "daemon");
|
||||
|
||||
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
|
||||
await expectHostNoLocalOnlyRows(page);
|
||||
@@ -113,7 +113,7 @@ test.describe("Settings host page", () => {
|
||||
|
||||
await expectHostPageVisible(page, serverId);
|
||||
await expectSettingsHeader(page, "Connections");
|
||||
await openHostSection(page, serverId, "host");
|
||||
await openHostSection(page, serverId, "daemon");
|
||||
await expectHostLabelDisplayed(page);
|
||||
await expectHostActionCards(page, serverId);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import {
|
||||
openSettingsSection,
|
||||
expectSettingsHeader,
|
||||
@@ -26,13 +25,7 @@ import {
|
||||
expectDiagnosticsContent,
|
||||
expectAboutContent,
|
||||
expectGeneralContent,
|
||||
expectAppearanceContent,
|
||||
seedSavedSettingsHosts,
|
||||
selectSettingsHost,
|
||||
expectSettingsHostPickerLabel,
|
||||
openSettingsHostSection,
|
||||
} from "./helpers/settings";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
test.describe("Settings sidebar navigation", () => {
|
||||
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
|
||||
@@ -50,10 +43,6 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await openSettingsSection(page, "general");
|
||||
await expectSettingsHeader(page, "General");
|
||||
await expectGeneralContent(page);
|
||||
|
||||
await openSettingsSection(page, "appearance");
|
||||
await expectSettingsHeader(page, "Appearance");
|
||||
await expectAppearanceContent(page);
|
||||
});
|
||||
|
||||
test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
|
||||
@@ -163,28 +152,4 @@ test.describe("Settings — compact master-detail", () => {
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expectSettingsSidebarVisible(page);
|
||||
});
|
||||
|
||||
test("switching the host picker on the settings list scopes host rows without navigating", async ({
|
||||
page,
|
||||
}) => {
|
||||
const primaryServerId = getServerId();
|
||||
const secondaryServerId = "srv_e2e_settings_secondary";
|
||||
const secondaryHostLabel = "Stable horse";
|
||||
const endpoint = `127.0.0.1:${getE2EDaemonPort()}`;
|
||||
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{ serverId: primaryServerId, label: "First horse", endpoint },
|
||||
{ serverId: secondaryServerId, label: secondaryHostLabel, endpoint },
|
||||
]);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
|
||||
await selectSettingsHost(page, secondaryServerId);
|
||||
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expectSettingsSidebarVisible(page);
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl";
|
||||
import { getTerminalBufferText, waitForTerminalContent } from "./helpers/terminal-perf";
|
||||
|
||||
interface TerminalSize {
|
||||
rows: number | null;
|
||||
cols: number | null;
|
||||
}
|
||||
|
||||
// The xterm/client view resizes immediately on split, so it is not enough to
|
||||
// prove the bug. It is the misleading symptom.
|
||||
async function readXtermSize(page: Page): Promise<TerminalSize> {
|
||||
return page.evaluate(() => {
|
||||
const term = (window as Window & { __paseoTerminal?: { rows?: number; cols?: number } })
|
||||
.__paseoTerminal;
|
||||
return {
|
||||
rows: typeof term?.rows === "number" ? term.rows : null,
|
||||
cols: typeof term?.cols === "number" ? term.cols : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// The PTY's own view of its size, reported by an `stty size` loop running in the
|
||||
// shell. This needs no focus or click, so it observes whether the daemon-side PTY
|
||||
// actually received the resize frame after the split.
|
||||
async function readLatestPtySize(page: Page): Promise<TerminalSize | null> {
|
||||
const text = await getTerminalBufferText(page);
|
||||
const matches = [...text.matchAll(/PTYSIZE (\d+) (\d+)/g)];
|
||||
const last = matches.at(-1);
|
||||
if (!last) {
|
||||
return null;
|
||||
}
|
||||
return { rows: Number(last[1]), cols: Number(last[2]) };
|
||||
}
|
||||
|
||||
function hasPtySizeReport(text: string): boolean {
|
||||
return /PTYSIZE \d+ \d+/.test(text);
|
||||
}
|
||||
|
||||
async function readXtermRows(page: Page): Promise<number | null> {
|
||||
return (await readXtermSize(page)).rows;
|
||||
}
|
||||
|
||||
async function ptyRowsMatchXtermRows(page: Page): Promise<boolean> {
|
||||
const xterm = await readXtermSize(page);
|
||||
const pty = await readLatestPtySize(page);
|
||||
return pty?.rows === xterm.rows;
|
||||
}
|
||||
|
||||
async function verifySplitDownResizesPty(page: Page, harness: TerminalE2EHarness): Promise<void> {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
|
||||
await withTerminalInApp(page, harness, { name: "split-resize" }, async () => {
|
||||
await harness.setupPrompt(page);
|
||||
|
||||
const terminal = harness.terminalSurface(page);
|
||||
// Continuously echo the PTY's own size. `stty size` prints "rows cols" and
|
||||
// reads the controlling tty, so this keeps reporting the real PTY size
|
||||
// without the test ever clicking the terminal back into focus.
|
||||
await terminal.pressSequentially(
|
||||
'while true; do echo "PTYSIZE $(stty size)"; sleep 0.3; done\n',
|
||||
{ delay: 0 },
|
||||
);
|
||||
await waitForTerminalContent(page, hasPtySizeReport, 10_000);
|
||||
|
||||
const beforeXterm = await readXtermSize(page);
|
||||
const beforePty = await readLatestPtySize(page);
|
||||
expect(beforePty, "the PTY should report its size before splitting").not.toBeNull();
|
||||
expect(beforeXterm.rows, "xterm should report its row count before splitting").not.toBeNull();
|
||||
expect(beforePty?.rows, "while focused, the PTY size should already match the xterm size").toBe(
|
||||
beforeXterm.rows,
|
||||
);
|
||||
|
||||
// Split the pane downward. This focuses the new empty pane, so the terminal
|
||||
// pane is unfocused at the exact moment its container shrinks.
|
||||
await page.getByRole("button", { name: "Split pane down" }).first().click();
|
||||
|
||||
// The local xterm renderer shrinks immediately on split - the part of the
|
||||
// behaviour that already works and that makes the bug look like nothing changed.
|
||||
await expect
|
||||
.poll(() => readXtermRows(page), {
|
||||
message: "xterm should shrink after splitting the pane down",
|
||||
timeout: 8_000,
|
||||
})
|
||||
.toBeLessThan(beforeXterm.rows ?? Number.POSITIVE_INFINITY);
|
||||
|
||||
// The PTY must follow the shrunken terminal, even though focus moved to the
|
||||
// new pane. Today it stays stuck at the pre-split size until the terminal is
|
||||
// clicked back into focus. This poll fails without the fix.
|
||||
await expect
|
||||
.poll(() => ptyRowsMatchXtermRows(page), {
|
||||
message: "the PTY rows should match the resized terminal after split-down",
|
||||
timeout: 8_000,
|
||||
})
|
||||
.toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Terminal split resize", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
let harness: TerminalE2EHarness;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-split-resize-" });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await harness?.cleanup();
|
||||
});
|
||||
|
||||
test("splitting the pane down resizes the PTY even though focus moves to the new pane", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await verifySplitDownResizesPty(page, harness);
|
||||
});
|
||||
});
|
||||
@@ -5,10 +5,8 @@ import { injectDesktopBridge } from "./helpers/desktop-updates";
|
||||
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
||||
|
||||
interface EditorOpenRecord {
|
||||
editorId: string;
|
||||
path: string;
|
||||
cwd?: string;
|
||||
mode?: "open" | "reveal";
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
function requireE2EEnv(name: string): string {
|
||||
@@ -45,8 +43,8 @@ async function chooseEditorTarget(page: Page, targetId: "vscode"): Promise<void>
|
||||
|
||||
async function expectEditorOpened(input: {
|
||||
recordPath: string;
|
||||
editorId: string;
|
||||
path: string;
|
||||
command: string;
|
||||
workspacePath: string;
|
||||
afterCount: number;
|
||||
}): Promise<void> {
|
||||
await expect
|
||||
@@ -55,7 +53,10 @@ async function expectEditorOpened(input: {
|
||||
const records = await readEditorOpenRecords(input.recordPath);
|
||||
return records
|
||||
.slice(input.afterCount)
|
||||
.some((record) => record.editorId === input.editorId && record.path === input.path);
|
||||
.some(
|
||||
(record) =>
|
||||
record.command === input.command && record.args.includes(input.workspacePath),
|
||||
);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
@@ -72,14 +73,7 @@ test.describe("Workspace open in editor", () => {
|
||||
const serverId = requireE2EEnv("E2E_SERVER_ID");
|
||||
const recordPath = requireE2EEnv("E2E_EDITOR_RECORD_PATH");
|
||||
await rm(recordPath, { force: true });
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
editorTargets: [
|
||||
{ id: "cursor", label: "Cursor", kind: "editor" },
|
||||
{ id: "vscode", label: "VS Code", kind: "editor" },
|
||||
],
|
||||
editorRecordPath: recordPath,
|
||||
});
|
||||
await injectDesktopBridge(page, { serverId });
|
||||
|
||||
const workspace = await withWorkspace({ prefix: "workspace-editor-target-" });
|
||||
await workspace.navigateTo();
|
||||
@@ -87,8 +81,8 @@ test.describe("Workspace open in editor", () => {
|
||||
await chooseEditorTarget(page, "vscode");
|
||||
await expectEditorOpened({
|
||||
recordPath,
|
||||
editorId: "vscode",
|
||||
path: workspace.repoPath,
|
||||
command: "code",
|
||||
workspacePath: workspace.repoPath,
|
||||
afterCount: 0,
|
||||
});
|
||||
const recordsAfterSelection = (await readEditorOpenRecords(recordPath)).length;
|
||||
@@ -100,8 +94,8 @@ test.describe("Workspace open in editor", () => {
|
||||
await page.getByTestId("workspace-open-in-editor-primary").click();
|
||||
await expectEditorOpened({
|
||||
recordPath,
|
||||
editorId: "vscode",
|
||||
path: workspace.repoPath,
|
||||
command: "code",
|
||||
workspacePath: workspace.repoPath,
|
||||
afterCount: recordsAfterSelection,
|
||||
});
|
||||
const recordsAfterReturnOpen = (await readEditorOpenRecords(recordPath)).length;
|
||||
@@ -111,8 +105,8 @@ test.describe("Workspace open in editor", () => {
|
||||
await page.getByTestId("workspace-open-in-editor-primary").click();
|
||||
await expectEditorOpened({
|
||||
recordPath,
|
||||
editorId: "vscode",
|
||||
path: workspace.repoPath,
|
||||
command: "code",
|
||||
workspacePath: workspace.repoPath,
|
||||
afterCount: recordsAfterReturnOpen,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
createWorkspaceThroughDaemon,
|
||||
openWorkspaceScriptsMenu,
|
||||
seedProjectForWorkspaceSetup,
|
||||
startWorkspaceScriptFromMenu,
|
||||
waitForWorkspaceSetupProgress,
|
||||
} from "./helpers/workspace-setup";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { buildHostWorkspaceRoute } from "../src/utils/host-routes";
|
||||
|
||||
test("scripts menu resizes when a service row grows after launch", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("script-menu-resize-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["sh -c 'echo setup complete'"],
|
||||
},
|
||||
scripts: {
|
||||
web: {
|
||||
type: "service",
|
||||
command:
|
||||
"node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, () => console.log('listening on ' + s.address().port))\"",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: `script-menu-resize-${Date.now()}`,
|
||||
});
|
||||
await completed;
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openWorkspaceScriptsMenu(page);
|
||||
|
||||
const menu = page.getByTestId("workspace-scripts-menu");
|
||||
const before = await menu.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
height: rect.height,
|
||||
clientHeight: element.clientHeight,
|
||||
scrollHeight: element.scrollHeight,
|
||||
};
|
||||
});
|
||||
|
||||
await startWorkspaceScriptFromMenu(page, "web");
|
||||
await expect(menu).toContainText("localhost:", { timeout: 15_000 });
|
||||
|
||||
const after = await menu.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const firstChild = element.firstElementChild;
|
||||
const childRect = firstChild?.getBoundingClientRect();
|
||||
return {
|
||||
height: rect.height,
|
||||
clientHeight: element.clientHeight,
|
||||
scrollHeight: element.scrollHeight,
|
||||
childHeight: childRect?.height ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
expect(after.height).toBeGreaterThan(before.height);
|
||||
expect(after.scrollHeight).toBeLessThanOrEqual(after.clientHeight + 1);
|
||||
expect(after.childHeight).toBeGreaterThan(before.height);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -74,6 +74,23 @@ platform :ios do
|
||||
build_number = valid_build.version
|
||||
UI.message("Submitting build #{build_number} as App Store version #{app_version} for review")
|
||||
|
||||
# Ensure an editable App Store version exists for this marketing version.
|
||||
# deliver requires a "Prepare for Submission" version on App Store Connect
|
||||
# and won't auto-create one when skip_metadata + skip_screenshots are set.
|
||||
app.ensure_version!(app_version, platform: Spaceship::ConnectAPI::Platform::IOS)
|
||||
|
||||
# Apple requires a non-empty "What's New" on every new App Store version
|
||||
# before it can be submitted for review. With skip_metadata: true, deliver
|
||||
# never sets this, so we populate it directly per-locale.
|
||||
edit_version = app.get_edit_app_store_version(platform: Spaceship::ConnectAPI::Platform::IOS)
|
||||
release_notes = "Bug fixes and improvements."
|
||||
edit_version.get_app_store_version_localizations.each do |localization|
|
||||
if localization.whats_new.to_s.strip.empty?
|
||||
UI.message("Setting whatsNew on #{localization.locale}")
|
||||
localization.update(attributes: { whatsNew: release_notes })
|
||||
end
|
||||
end
|
||||
|
||||
deliver(
|
||||
api_key: api_key,
|
||||
app_identifier: APP_IDENTIFIER,
|
||||
@@ -81,13 +98,10 @@ platform :ios do
|
||||
build_number: build_number.to_s,
|
||||
submit_for_review: true,
|
||||
automatic_release: true,
|
||||
reject_if_possible: true,
|
||||
release_notes: {
|
||||
"en-US" => "Bug fixes and improvements.",
|
||||
},
|
||||
force: true,
|
||||
skip_binary_upload: true,
|
||||
skip_screenshots: true,
|
||||
skip_metadata: true,
|
||||
precheck_include_in_app_purchases: false,
|
||||
submission_information: {
|
||||
add_id_info_uses_idfa: false,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.91",
|
||||
"version": "0.1.87",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "npm run start:expo",
|
||||
"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",
|
||||
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
|
||||
@@ -26,7 +26,6 @@
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"build": "npm run build:web",
|
||||
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
|
||||
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -17,18 +17,14 @@
|
||||
<title>%WEB_TITLE%</title>
|
||||
<!-- The `react-native-web` recommended style reset: https://necolas.github.io/react-native-web/docs/setup/#root-element -->
|
||||
<style id="expo-reset">
|
||||
/* Keep the app shell fixed to the viewport. */
|
||||
/* These styles make the body full-height */
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
/* These styles smooth text rendering in the app shell. */
|
||||
/* These styles disable body scrolling if you are using <ScrollView> */
|
||||
body {
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -42,12 +38,8 @@
|
||||
/* These styles make the root element full-height */
|
||||
#root {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
|
||||
const baseUrl = process.env.PASEO_PROFILE_APP_URL ?? "http://localhost:19010";
|
||||
const serverId = requiredEnv("PASEO_PROFILE_SERVER_ID");
|
||||
const workspaceId = requiredEnv("PASEO_PROFILE_WORKSPACE_ID");
|
||||
const agentId = requiredEnv("PASEO_PROFILE_AGENT_ID");
|
||||
const switchCount = Number(process.env.PASEO_PROFILE_SWITCH_COUNT ?? 6);
|
||||
const switchWaitMs = Number(process.env.PASEO_PROFILE_SWITCH_WAIT_MS ?? 250);
|
||||
const idleWaitMs = Number(process.env.PASEO_PROFILE_IDLE_WAIT_MS ?? 0);
|
||||
const dumpCommits = process.env.PASEO_PROFILE_DUMP_COMMITS === "1";
|
||||
|
||||
function requiredEnv(name) {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function createTerminal() {
|
||||
const output = execFileSync(
|
||||
"npm",
|
||||
[
|
||||
"run",
|
||||
"cli",
|
||||
"--",
|
||||
"terminal",
|
||||
"create",
|
||||
"--cwd",
|
||||
workspaceId,
|
||||
"--name",
|
||||
"Tab Switch Profile",
|
||||
"--json",
|
||||
],
|
||||
{ cwd: repoRoot, encoding: "utf8" },
|
||||
);
|
||||
const match = output.match(/\{[\s\S]*\}/);
|
||||
if (!match) {
|
||||
throw new Error(`Could not parse terminal create output: ${output}`);
|
||||
}
|
||||
return JSON.parse(match[0]).id;
|
||||
}
|
||||
|
||||
function killTerminal(terminalId) {
|
||||
execFileSync("npm", ["run", "cli", "--", "terminal", "kill", terminalId], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
const createdTerminalId = process.env.PASEO_PROFILE_TERMINAL_ID ? null : createTerminal();
|
||||
const terminalId = process.env.PASEO_PROFILE_TERMINAL_ID ?? createdTerminalId;
|
||||
const workspaceSegment = `b64_${Buffer.from(workspaceId, "utf8").toString("base64url")}`;
|
||||
const workspaceUrl = `${baseUrl}/h/${serverId}/workspace/${workspaceSegment}`;
|
||||
|
||||
function tabTestId(kind, id) {
|
||||
return `workspace-tab-${kind}_${id}`;
|
||||
}
|
||||
|
||||
function summarize(samples) {
|
||||
const byId = new Map();
|
||||
for (const sample of samples) {
|
||||
const current = byId.get(sample.id) ?? {
|
||||
renders: 0,
|
||||
actualDuration: 0,
|
||||
baseDuration: 0,
|
||||
};
|
||||
current.renders += 1;
|
||||
current.actualDuration += sample.actualDuration;
|
||||
current.baseDuration += sample.baseDuration;
|
||||
byId.set(sample.id, current);
|
||||
}
|
||||
return [...byId.entries()]
|
||||
.map(([id, value]) => Object.assign({ id }, value))
|
||||
.sort((left, right) => right.actualDuration - left.actualDuration);
|
||||
}
|
||||
|
||||
function summarizeCommits(samples) {
|
||||
const byCommit = new Map();
|
||||
for (const sample of samples) {
|
||||
const key = String(sample.commitTime);
|
||||
const current = byCommit.get(key) ?? {
|
||||
commitTime: sample.commitTime,
|
||||
totalActualDuration: 0,
|
||||
samples: [],
|
||||
};
|
||||
current.totalActualDuration += sample.actualDuration;
|
||||
current.samples.push({
|
||||
id: sample.id,
|
||||
actualDuration: sample.actualDuration,
|
||||
baseDuration: sample.baseDuration,
|
||||
});
|
||||
byCommit.set(key, current);
|
||||
}
|
||||
|
||||
return [...byCommit.values()].sort((left, right) => left.commitTime - right.commitTime);
|
||||
}
|
||||
|
||||
async function openIntent(page, value) {
|
||||
const url = `${workspaceUrl}?renderProfile=1&open=${encodeURIComponent(value)}`;
|
||||
await page.goto(url, { waitUntil: "domcontentloaded" });
|
||||
await page.getByTestId("workspace-tabs-row").waitFor({ timeout: 60_000 });
|
||||
}
|
||||
|
||||
async function clickTab(page, testId) {
|
||||
await page.getByTestId(testId).click();
|
||||
await page.waitForTimeout(switchWaitMs);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
||||
|
||||
try {
|
||||
await openIntent(page, `agent:${agentId}`);
|
||||
await openIntent(page, `terminal:${terminalId}`);
|
||||
|
||||
const agentTab = tabTestId("agent", agentId);
|
||||
const terminalTab = tabTestId("terminal", terminalId);
|
||||
await page.getByTestId(agentTab).waitFor({ timeout: 60_000 });
|
||||
await page.getByTestId(terminalTab).waitFor({ timeout: 60_000 });
|
||||
|
||||
await clickTab(page, agentTab);
|
||||
await clickTab(page, terminalTab);
|
||||
await page.waitForTimeout(500);
|
||||
await page.evaluate(() => globalThis.__PASEO_RESET_RENDER_PROFILE__?.());
|
||||
|
||||
if (idleWaitMs > 0) {
|
||||
await page.waitForTimeout(idleWaitMs);
|
||||
}
|
||||
|
||||
for (let index = 0; index < switchCount; index += 1) {
|
||||
await clickTab(page, agentTab);
|
||||
await clickTab(page, terminalTab);
|
||||
}
|
||||
|
||||
const samples = await page.evaluate(() => globalThis.__PASEO_RENDER_PROFILE__ ?? []);
|
||||
const reasons = await page.evaluate(() => globalThis.__PASEO_RENDER_PROFILE_REASONS__ ?? {});
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
appUrl: baseUrl,
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
terminalId,
|
||||
switchCount,
|
||||
switchWaitMs,
|
||||
idleWaitMs,
|
||||
samples: samples.length,
|
||||
summary: summarize(samples),
|
||||
reasons,
|
||||
commits: dumpCommits ? summarizeCommits(samples) : undefined,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
if (createdTerminalId) {
|
||||
killTerminal(createdTerminalId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -32,11 +32,30 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
|
||||
autoscrollToTopThreshold: 0,
|
||||
});
|
||||
const HISTORY_START_THRESHOLD_PX = 96;
|
||||
const SCROLL_TO_INDEX_RETRY_DELAY_MS = 80;
|
||||
|
||||
interface NativeScrollToIndexFailedInfo {
|
||||
index: number;
|
||||
highestMeasuredFrameIndex: number;
|
||||
averageItemLength: number;
|
||||
}
|
||||
|
||||
interface NativeScrollIndexFallbackInput {
|
||||
index: number;
|
||||
averageItemLength: number;
|
||||
}
|
||||
|
||||
function keyExtractor(item: { id: string }): string {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
export function getNativeScrollToIndexFallbackOffset(input: NativeScrollIndexFallbackInput) {
|
||||
if (!Number.isFinite(input.averageItemLength) || input.averageItemLength <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, input.averageItemLength * input.index);
|
||||
}
|
||||
|
||||
function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) {
|
||||
const {
|
||||
agentId,
|
||||
@@ -69,6 +88,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
});
|
||||
const scrollOffsetYRef = useRef(0);
|
||||
const programmaticScrollEventBudgetRef = useRef(0);
|
||||
const scrollToIndexRetryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
||||
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
|
||||
const historyStartReadyRef = useRef(false);
|
||||
@@ -87,6 +107,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearScrollToIndexRetry = useCallback(() => {
|
||||
if (scrollToIndexRetryTimeoutRef.current !== null) {
|
||||
clearTimeout(scrollToIndexRetryTimeoutRef.current);
|
||||
scrollToIndexRetryTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markNativeViewportSettling = useCallback(() => {
|
||||
clearNativeViewportSettling();
|
||||
setIsNativeViewportSettling(true);
|
||||
@@ -149,6 +176,37 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
scrollToBottom,
|
||||
});
|
||||
|
||||
const scrollToHistoryIndex = useCallback(
|
||||
(index: number) => {
|
||||
programmaticScrollEventBudgetRef.current = 3;
|
||||
clearScrollToIndexRetry();
|
||||
flatListRef.current?.scrollToIndex({
|
||||
index,
|
||||
animated: true,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
},
|
||||
[clearScrollToIndexRetry],
|
||||
);
|
||||
|
||||
const handleScrollToIndexFailed = useStableEvent((info: NativeScrollToIndexFailedInfo) => {
|
||||
programmaticScrollEventBudgetRef.current = 3;
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: getNativeScrollToIndexFallbackOffset(info),
|
||||
animated: true,
|
||||
});
|
||||
clearScrollToIndexRetry();
|
||||
scrollToIndexRetryTimeoutRef.current = setTimeout(() => {
|
||||
scrollToIndexRetryTimeoutRef.current = null;
|
||||
programmaticScrollEventBudgetRef.current = 3;
|
||||
flatListRef.current?.scrollToIndex({
|
||||
index: info.index,
|
||||
animated: true,
|
||||
viewPosition: 0.5,
|
||||
});
|
||||
}, SCROLL_TO_INDEX_RETRY_DELAY_MS);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
streamViewportMetricsRef.current = {
|
||||
containerKey: "native-virtualized",
|
||||
@@ -161,6 +219,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
};
|
||||
scrollOffsetYRef.current = 0;
|
||||
clearNativeViewportSettling();
|
||||
clearScrollToIndexRetry();
|
||||
setIsNativeViewportSettling(false);
|
||||
historyStartReadyRef.current = false;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
@@ -169,7 +228,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [agentId, clearNativeViewportSettling]);
|
||||
}, [agentId, clearNativeViewportSettling, clearScrollToIndexRetry]);
|
||||
|
||||
useEffect(() => {
|
||||
const keyboardEvents = [
|
||||
@@ -190,8 +249,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
subscription.remove();
|
||||
}
|
||||
clearNativeViewportSettling();
|
||||
clearScrollToIndexRetry();
|
||||
};
|
||||
}, [clearNativeViewportSettling, markNativeViewportSettling]);
|
||||
}, [clearNativeViewportSettling, clearScrollToIndexRetry, markNativeViewportSettling]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomAnchorController.prepareForStickyContentChange();
|
||||
@@ -209,6 +269,17 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
bottomAnchorController.prepareForStickyViewportChange();
|
||||
markNativeViewportSettling();
|
||||
},
|
||||
scrollToStreamItem: (target) => {
|
||||
programmaticScrollEventBudgetRef.current = 3;
|
||||
if (target.source === "liveHead") {
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: 0,
|
||||
animated: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
scrollToHistoryIndex(target.index);
|
||||
},
|
||||
};
|
||||
viewportRef.current = handle;
|
||||
return () => {
|
||||
@@ -216,7 +287,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
viewportRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [agentId, bottomAnchorController, markNativeViewportSettling, viewportRef]);
|
||||
}, [
|
||||
agentId,
|
||||
bottomAnchorController,
|
||||
markNativeViewportSettling,
|
||||
scrollToHistoryIndex,
|
||||
viewportRef,
|
||||
]);
|
||||
|
||||
const handleScroll = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
||||
@@ -360,6 +437,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
onScrollToIndexFailed={handleScrollToIndexFailed}
|
||||
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
|
||||
initialNumToRender={40}
|
||||
maxToRenderPerBatch={40}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, {
|
||||
Fragment,
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -67,6 +66,10 @@ function scrollElementToBottom(
|
||||
});
|
||||
}
|
||||
|
||||
function getStreamItemElementId(itemId: string): string {
|
||||
return `agent-stream-row-${itemId}`;
|
||||
}
|
||||
|
||||
function syncNearBottom(
|
||||
scrollContainer: HTMLElement | null,
|
||||
onNearBottomChange: (value: boolean) => void,
|
||||
@@ -468,6 +471,21 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
scheduleStickToBottom();
|
||||
},
|
||||
scrollToStreamItem: (target) => {
|
||||
setFollowOutput(false);
|
||||
cancelPendingStickToBottom();
|
||||
if (target.source === "historyVirtualized") {
|
||||
rowVirtualizer.scrollToIndex(target.index, { align: "center" });
|
||||
return;
|
||||
}
|
||||
const row = document.getElementById(getStreamItemElementId(target.itemId));
|
||||
row?.scrollIntoView({ block: "center", behavior: "auto" });
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (scrollContainer) {
|
||||
lastKnownScrollTopRef.current = scrollContainer.scrollTop;
|
||||
syncNearBottom(scrollContainer, onNearBottomChange);
|
||||
}
|
||||
},
|
||||
};
|
||||
viewportRef.current = handle;
|
||||
return () => {
|
||||
@@ -476,7 +494,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
cancelPendingStickToBottom();
|
||||
};
|
||||
}, [cancelPendingStickToBottom, forceStickToBottom, scheduleStickToBottom, viewportRef]);
|
||||
}, [
|
||||
cancelPendingStickToBottom,
|
||||
forceStickToBottom,
|
||||
onNearBottomChange,
|
||||
rowVirtualizer,
|
||||
scheduleStickToBottom,
|
||||
viewportRef,
|
||||
]);
|
||||
|
||||
const contentContainerStyle = useMemo((): CSSProperties => {
|
||||
return {
|
||||
@@ -520,14 +545,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
);
|
||||
const mountedHistoryRows = useMemo(() => {
|
||||
return segments.historyMounted.map((item, index) => (
|
||||
<Fragment key={item.id}>
|
||||
<div id={getStreamItemElementId(item.id)} key={item.id}>
|
||||
{renderHistoryMountedRow(item, index, segments.historyMounted)}
|
||||
</Fragment>
|
||||
</div>
|
||||
));
|
||||
}, [renderHistoryMountedRow, segments.historyMounted]);
|
||||
const liveHeadRows = useMemo(() => {
|
||||
return segments.liveHead.map((item, index) => (
|
||||
<Fragment key={item.id}>{renderLiveHeadRow(item, index, segments.liveHead)}</Fragment>
|
||||
<div id={getStreamItemElementId(item.id)} key={item.id}>
|
||||
{renderLiveHeadRow(item, index, segments.liveHead)}
|
||||
</div>
|
||||
));
|
||||
}, [renderLiveHeadRow, segments.liveHead]);
|
||||
const liveAuxiliary = useMemo(() => {
|
||||
@@ -570,6 +597,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
id={getStreamItemElementId(item.id)}
|
||||
ref={measureVirtualizedRowElement}
|
||||
style={renderVirtualRowStyle(virtualRow.start)}
|
||||
>
|
||||
|
||||
@@ -42,6 +42,13 @@ export interface StreamEdgeSlotProps {
|
||||
export interface StreamViewportHandle {
|
||||
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
|
||||
prepareForViewportChange: () => void;
|
||||
scrollToStreamItem: (target: StreamScrollTarget) => void;
|
||||
}
|
||||
|
||||
export interface StreamScrollTarget {
|
||||
source: "historyVirtualized" | "historyMounted" | "liveHead";
|
||||
index: number;
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
export interface StreamSegmentRenderers {
|
||||
|
||||
@@ -36,12 +36,12 @@ import {
|
||||
CompactionMarker,
|
||||
MessageOuterSpacingProvider,
|
||||
type InlinePathTarget,
|
||||
type MessageFindHighlight,
|
||||
} from "@/components/message";
|
||||
import { PlanCard } from "@/components/plan-card";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentPermissionAction,
|
||||
AgentPermissionResponse,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
@@ -59,6 +59,11 @@ import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
|
||||
import { CompletedTurnFooterRow, TurnFooter, type TurnContentStrategy } from "./turn-footer";
|
||||
import { layoutStream, type StreamLayoutItem } from "./layout";
|
||||
import {
|
||||
buildAgentStreamSearchModel,
|
||||
findAgentStreamSearchMatches,
|
||||
type AgentStreamSearchMatch,
|
||||
} from "@/components/agent-stream-search-model";
|
||||
import {
|
||||
type BottomAnchorLocalRequest,
|
||||
type BottomAnchorRouteRequest,
|
||||
@@ -76,9 +81,14 @@ import {
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import {
|
||||
FindBar,
|
||||
type PaneFindMatchState,
|
||||
type UsePaneFindResult,
|
||||
usePaneFind,
|
||||
} from "@/panels/pane-find";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||
|
||||
function renderLiveAuxiliaryNode(input: {
|
||||
pendingPermissions: ReactNode;
|
||||
@@ -199,6 +209,69 @@ function renderLiveHeadStreamItem(input: {
|
||||
return input.renderStreamItem(layoutItem);
|
||||
}
|
||||
|
||||
function createAgentStreamFindMatchState(input: {
|
||||
query: string;
|
||||
matches: AgentStreamSearchMatch[];
|
||||
currentMatchId: string | null;
|
||||
}): PaneFindMatchState {
|
||||
if (input.query.length === 0) {
|
||||
return { status: "empty" };
|
||||
}
|
||||
if (input.matches.length === 0) {
|
||||
return { status: "no-match" };
|
||||
}
|
||||
const currentIndex = Math.max(
|
||||
0,
|
||||
input.matches.findIndex((match) => match.id === input.currentMatchId),
|
||||
);
|
||||
return {
|
||||
status: "matched",
|
||||
current: currentIndex + 1,
|
||||
total: input.matches.length,
|
||||
};
|
||||
}
|
||||
|
||||
function createAgentStreamFindHighlightsByItemId(input: {
|
||||
matches: AgentStreamSearchMatch[];
|
||||
currentMatchId: string | null;
|
||||
}): Map<string, MessageFindHighlight[]> {
|
||||
const highlightsByItemId = new Map<string, MessageFindHighlight[]>();
|
||||
|
||||
for (const match of input.matches) {
|
||||
if (match.segmentKey !== "text") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemKind = match.entry.item.kind;
|
||||
if (itemKind !== "user_message" && itemKind !== "assistant_message") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const highlights = highlightsByItemId.get(match.entry.item.id) ?? [];
|
||||
highlights.push({
|
||||
id: match.id,
|
||||
start: match.start,
|
||||
end: match.end,
|
||||
isCurrent: match.id === input.currentMatchId,
|
||||
});
|
||||
highlightsByItemId.set(match.entry.item.id, highlights);
|
||||
}
|
||||
|
||||
return highlightsByItemId;
|
||||
}
|
||||
|
||||
interface AgentStreamFindState {
|
||||
query: string;
|
||||
matches: AgentStreamSearchMatch[];
|
||||
currentMatchId: string | null;
|
||||
}
|
||||
|
||||
const EMPTY_AGENT_STREAM_FIND_STATE: AgentStreamFindState = {
|
||||
query: "",
|
||||
matches: [],
|
||||
currentMatchId: null,
|
||||
};
|
||||
|
||||
export interface AgentStreamViewHandle {
|
||||
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
|
||||
prepareForViewportChange(): void;
|
||||
@@ -216,18 +289,6 @@ export interface AgentStreamViewProps {
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}
|
||||
|
||||
const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
|
||||
"supportsStreaming",
|
||||
"supportsSessionPersistence",
|
||||
"supportsDynamicModes",
|
||||
"supportsMcpServers",
|
||||
"supportsReasoningStream",
|
||||
"supportsToolInvocations",
|
||||
"supportsRewindConversation",
|
||||
"supportsRewindFiles",
|
||||
"supportsRewindBoth",
|
||||
];
|
||||
|
||||
const EMPTY_STREAM_HEAD: StreamItem[] = [];
|
||||
|
||||
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
|
||||
@@ -387,6 +448,154 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
streamRenderStrategy,
|
||||
],
|
||||
);
|
||||
const searchModel = useMemo(
|
||||
() =>
|
||||
buildAgentStreamSearchModel({
|
||||
streamItems,
|
||||
streamHead: streamHead ?? [],
|
||||
platform: isWeb ? "web" : "native",
|
||||
isMobileBreakpoint: isMobile,
|
||||
cwd: agent.cwd,
|
||||
}),
|
||||
[agent.cwd, isMobile, streamHead, streamItems],
|
||||
);
|
||||
const [findState, setFindState] = useState<AgentStreamFindState>(EMPTY_AGENT_STREAM_FIND_STATE);
|
||||
const findQuery = findState.query;
|
||||
const findMatches = findState.matches;
|
||||
const currentFindMatchId = findState.currentMatchId;
|
||||
const currentFindMatchIndex = useMemo(() => {
|
||||
if (findMatches.length === 0) {
|
||||
return -1;
|
||||
}
|
||||
const existingIndex = findMatches.findIndex((match) => match.id === currentFindMatchId);
|
||||
return existingIndex >= 0 ? existingIndex : 0;
|
||||
}, [currentFindMatchId, findMatches]);
|
||||
const findMatchState = useMemo(
|
||||
() =>
|
||||
createAgentStreamFindMatchState({
|
||||
query: findQuery,
|
||||
matches: findMatches,
|
||||
currentMatchId: currentFindMatchId,
|
||||
}),
|
||||
[currentFindMatchId, findMatches, findQuery],
|
||||
);
|
||||
const findHighlightsByItemId = useMemo(
|
||||
() =>
|
||||
createAgentStreamFindHighlightsByItemId({
|
||||
matches: findMatches,
|
||||
currentMatchId: currentFindMatchId,
|
||||
}),
|
||||
[currentFindMatchId, findMatches],
|
||||
);
|
||||
const scrollFindMatchIntoView = useCallback((match: AgentStreamSearchMatch | undefined) => {
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
viewportRef.current?.scrollToStreamItem({
|
||||
source: match.entry.source,
|
||||
index: match.entry.index,
|
||||
itemId: match.entry.item.id,
|
||||
});
|
||||
}, []);
|
||||
const paneFind = usePaneFind({
|
||||
matchState: findMatchState,
|
||||
onQuery: (query) => {
|
||||
const nextMatches = findAgentStreamSearchMatches({
|
||||
model: searchModel,
|
||||
query,
|
||||
});
|
||||
const nextMatchId = nextMatches[0]?.id ?? null;
|
||||
setFindState({ query, matches: nextMatches, currentMatchId: nextMatchId });
|
||||
scrollFindMatchIntoView(nextMatches[0]);
|
||||
return createAgentStreamFindMatchState({
|
||||
query,
|
||||
matches: nextMatches,
|
||||
currentMatchId: nextMatchId,
|
||||
});
|
||||
},
|
||||
onNext: () => {
|
||||
if (findMatches.length === 0) {
|
||||
return findMatchState;
|
||||
}
|
||||
const nextIndex = (currentFindMatchIndex + 1) % findMatches.length;
|
||||
const nextMatch = findMatches[nextIndex];
|
||||
setFindState((current) => ({
|
||||
...current,
|
||||
currentMatchId: nextMatch?.id ?? null,
|
||||
}));
|
||||
scrollFindMatchIntoView(nextMatch);
|
||||
return createAgentStreamFindMatchState({
|
||||
query: findQuery,
|
||||
matches: findMatches,
|
||||
currentMatchId: nextMatch?.id ?? null,
|
||||
});
|
||||
},
|
||||
onPrev: () => {
|
||||
if (findMatches.length === 0) {
|
||||
return findMatchState;
|
||||
}
|
||||
const nextIndex = (currentFindMatchIndex - 1 + findMatches.length) % findMatches.length;
|
||||
const nextMatch = findMatches[nextIndex];
|
||||
setFindState((current) => ({
|
||||
...current,
|
||||
currentMatchId: nextMatch?.id ?? null,
|
||||
}));
|
||||
scrollFindMatchIntoView(nextMatch);
|
||||
return createAgentStreamFindMatchState({
|
||||
query: findQuery,
|
||||
matches: findMatches,
|
||||
currentMatchId: nextMatch?.id ?? null,
|
||||
});
|
||||
},
|
||||
onClose: () => {
|
||||
setFindState(EMPTY_AGENT_STREAM_FIND_STATE);
|
||||
},
|
||||
});
|
||||
useEffect(() => {
|
||||
setFindState((current) => {
|
||||
if (current.query.length === 0) {
|
||||
return current.currentMatchId === null && current.matches.length === 0
|
||||
? current
|
||||
: EMPTY_AGENT_STREAM_FIND_STATE;
|
||||
}
|
||||
|
||||
const nextMatches = findAgentStreamSearchMatches({
|
||||
model: searchModel,
|
||||
query: current.query,
|
||||
});
|
||||
let keepsCurrentMatch = false;
|
||||
if (current.currentMatchId) {
|
||||
for (const match of nextMatches) {
|
||||
if (match.id === current.currentMatchId) {
|
||||
keepsCurrentMatch = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const nextMatchId = keepsCurrentMatch
|
||||
? current.currentMatchId
|
||||
: (nextMatches[0]?.id ?? null);
|
||||
|
||||
let matchesUnchanged = current.matches.length === nextMatches.length;
|
||||
if (matchesUnchanged) {
|
||||
for (let index = 0; index < current.matches.length; index += 1) {
|
||||
if (current.matches[index]?.id !== nextMatches[index]?.id) {
|
||||
matchesUnchanged = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matchesUnchanged && current.currentMatchId === nextMatchId) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
query: current.query,
|
||||
matches: nextMatches,
|
||||
currentMatchId: nextMatchId,
|
||||
};
|
||||
});
|
||||
}, [searchModel]);
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -437,10 +646,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
client={client}
|
||||
isFirstInGroup={layoutItem.isFirstInUserGroup}
|
||||
isLastInGroup={layoutItem.isLastInUserGroup}
|
||||
findHighlights={findHighlightsByItemId.get(item.id)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[agent.capabilities, agentId, client, resolvedServerId],
|
||||
[agent.capabilities, agentId, client, findHighlightsByItemId, resolvedServerId],
|
||||
);
|
||||
|
||||
const renderAssistantMessageItem = useCallback(
|
||||
@@ -460,11 +670,19 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
serverId={resolvedServerId}
|
||||
client={client}
|
||||
spacing={layoutItem.assistantSpacing}
|
||||
findHighlights={findHighlightsByItemId.get(item.id)}
|
||||
/>
|
||||
</AssistantFileLinkResolverProvider>
|
||||
);
|
||||
},
|
||||
[client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
|
||||
[
|
||||
client,
|
||||
findHighlightsByItemId,
|
||||
handleInlinePathPress,
|
||||
resolvedServerId,
|
||||
toast,
|
||||
workspaceRoot,
|
||||
],
|
||||
);
|
||||
|
||||
const renderThoughtItem = useCallback(
|
||||
@@ -714,6 +932,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return (
|
||||
<ToolCallSheetProvider>
|
||||
<View style={stylesheet.container}>
|
||||
<AgentStreamFindBarSlot paneFind={paneFind} />
|
||||
<MessageOuterSpacingProvider disableOuterSpacing>
|
||||
{streamRenderStrategy.render({
|
||||
agentId,
|
||||
@@ -759,70 +978,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
},
|
||||
);
|
||||
|
||||
function agentCapabilityFlagsEqual(
|
||||
left: AgentCapabilityFlags | undefined,
|
||||
right: AgentCapabilityFlags | undefined,
|
||||
): boolean {
|
||||
return AGENT_CAPABILITY_FLAG_KEYS.every((key) => left?.[key] === right?.[key]);
|
||||
}
|
||||
|
||||
function collectAgentScreenAgentDiffs(left: AgentScreenAgent, right: AgentScreenAgent): string[] {
|
||||
const reasons: string[] = [];
|
||||
if (left.serverId !== right.serverId) reasons.push("agent.serverId");
|
||||
if (left.id !== right.id) reasons.push("agent.id");
|
||||
if (left.status !== right.status) reasons.push("agent.status");
|
||||
if (left.cwd !== right.cwd) reasons.push("agent.cwd");
|
||||
if (!agentCapabilityFlagsEqual(left.capabilities, right.capabilities)) {
|
||||
reasons.push("agent.capabilities");
|
||||
}
|
||||
if (left.lastError !== right.lastError) reasons.push("agent.lastError");
|
||||
if (left.projectPlacement?.checkout?.cwd !== right.projectPlacement?.checkout?.cwd) {
|
||||
reasons.push("agent.projectPlacement.checkout.cwd");
|
||||
}
|
||||
if (left.projectPlacement?.checkout?.isGit !== right.projectPlacement?.checkout?.isGit) {
|
||||
reasons.push("agent.projectPlacement.checkout.isGit");
|
||||
}
|
||||
return reasons;
|
||||
}
|
||||
|
||||
function bottomAnchorRouteRequestsEqual(
|
||||
left: BottomAnchorRouteRequest | null | undefined,
|
||||
right: BottomAnchorRouteRequest | null | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
left?.agentId === right?.agentId &&
|
||||
left?.reason === right?.reason &&
|
||||
left?.requestKey === right?.requestKey
|
||||
);
|
||||
}
|
||||
|
||||
function agentStreamViewPropsEqual(
|
||||
left: AgentStreamViewProps,
|
||||
right: AgentStreamViewProps,
|
||||
): boolean {
|
||||
const reasons: string[] = [];
|
||||
if (left.agentId !== right.agentId) reasons.push("agentId");
|
||||
if (left.serverId !== right.serverId) reasons.push("serverId");
|
||||
reasons.push(...collectAgentScreenAgentDiffs(left.agent, right.agent));
|
||||
if (left.streamItems !== right.streamItems) reasons.push("streamItems");
|
||||
if (left.pendingPermissions !== right.pendingPermissions) reasons.push("pendingPermissions");
|
||||
if (
|
||||
!bottomAnchorRouteRequestsEqual(left.routeBottomAnchorRequest, right.routeBottomAnchorRequest)
|
||||
) {
|
||||
reasons.push("routeBottomAnchorRequest");
|
||||
}
|
||||
if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) {
|
||||
reasons.push("isAuthoritativeHistoryReady");
|
||||
}
|
||||
if (left.toast !== right.toast) reasons.push("toast");
|
||||
if (left.onOpenWorkspaceFile !== right.onOpenWorkspaceFile) reasons.push("onOpenWorkspaceFile");
|
||||
recordRenderProfileReasons(`AgentStreamView:${right.agentId}`, reasons);
|
||||
return reasons.length === 0;
|
||||
}
|
||||
|
||||
export const AgentStreamView = memo(AgentStreamViewComponent, agentStreamViewPropsEqual);
|
||||
export const AgentStreamView = memo(AgentStreamViewComponent);
|
||||
AgentStreamView.displayName = "AgentStreamView";
|
||||
|
||||
function AgentStreamFindBarSlot({ paneFind }: { paneFind: UsePaneFindResult }) {
|
||||
return paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null;
|
||||
}
|
||||
|
||||
interface ToolCallSlotProps extends Omit<
|
||||
ComponentProps<typeof ToolCall>,
|
||||
"onInlineDetailsExpandedChange"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import "@/styles/unistyles";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { PortalProvider } from "@gorhom/portal";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as Linking from "expo-linking";
|
||||
@@ -41,8 +40,6 @@ import {
|
||||
} from "@/contexts/horizontal-scroll-context";
|
||||
import { SessionProvider } from "@/contexts/session-context";
|
||||
import {
|
||||
MOBILE_VISUAL_PANEL_AGENT,
|
||||
MOBILE_VISUAL_PANEL_AGENT_LIST,
|
||||
SidebarAnimationProvider,
|
||||
useSidebarAnimation,
|
||||
} from "@/contexts/sidebar-animation-context";
|
||||
@@ -76,7 +73,6 @@ import {
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import { applyAppearance } from "@/screens/settings/appearance/apply-appearance";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
@@ -496,6 +492,7 @@ function MobileGestureWrapper({
|
||||
children: ReactNode;
|
||||
chromeEnabled: boolean;
|
||||
}) {
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
||||
const horizontalScroll = useHorizontalScrollOptional();
|
||||
const {
|
||||
@@ -505,13 +502,12 @@ function MobileGestureWrapper({
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
mobileVisualPanel,
|
||||
gestureAnimatingRef,
|
||||
openGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
const touchStartX = useSharedValue(0);
|
||||
const touchStartY = useSharedValue(0);
|
||||
const openGestureEnabled = chromeEnabled;
|
||||
const openGestureEnabled = chromeEnabled && mobileView === "agent";
|
||||
|
||||
const handleGestureOpen = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
@@ -541,11 +537,6 @@ function MobileGestureWrapper({
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (horizontalScroll?.isAnyScrolledRight.value) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
@@ -587,11 +578,9 @@ function MobileGestureWrapper({
|
||||
isGesturing.value = false;
|
||||
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||
if (shouldOpen) {
|
||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST;
|
||||
animateToOpen();
|
||||
runOnJS(handleGestureOpen)();
|
||||
} else {
|
||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
|
||||
animateToClose();
|
||||
}
|
||||
})
|
||||
@@ -603,7 +592,6 @@ function MobileGestureWrapper({
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
mobileVisualPanel,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
handleGestureOpen,
|
||||
@@ -637,27 +625,6 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [settingsLoading, settings.theme]);
|
||||
|
||||
// Apply font / size / syntax appearance settings on mount and when they change.
|
||||
// Sibling to the theme effect above; order is irrelevant because both patch all
|
||||
// six registered theme keys, so the active key is always current.
|
||||
useEffect(() => {
|
||||
if (settingsLoading) return;
|
||||
applyAppearance({
|
||||
uiFontFamily: settings.uiFontFamily,
|
||||
monoFontFamily: settings.monoFontFamily,
|
||||
uiFontSize: settings.uiFontSize,
|
||||
codeFontSize: settings.codeFontSize,
|
||||
syntaxTheme: settings.syntaxTheme,
|
||||
});
|
||||
}, [
|
||||
settingsLoading,
|
||||
settings.uiFontFamily,
|
||||
settings.monoFontFamily,
|
||||
settings.uiFontSize,
|
||||
settings.codeFontSize,
|
||||
settings.syntaxTheme,
|
||||
]);
|
||||
|
||||
return (
|
||||
<VoiceProvider>
|
||||
<DesktopWindowControlsSync enabled={!settingsLoading} />
|
||||
@@ -932,20 +899,17 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
// PortalProvider must stay inside normal app-wide context providers here.
|
||||
// PortalProvider must remain the innermost global provider here.
|
||||
// `@gorhom/portal` renders portaled children at the host's location in the
|
||||
// tree, so any context a portaled sheet might consume (QueryClient, theme,
|
||||
// auth, settings, …) must wrap PortalProvider — not be wrapped by it.
|
||||
// BottomSheetModalProvider is the exception: Gorhom modals consume portal
|
||||
// context and need one shared provider for sibling sheets to stack.
|
||||
// Adding a new global provider? Put it above PortalProvider.
|
||||
function RootProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<QueryProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
<PortalProvider>{children}</PortalProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
</QueryProvider>
|
||||
|
||||
@@ -9,10 +9,14 @@ export default function HostNewWorkspaceRoute() {
|
||||
projectId?: string;
|
||||
}>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
const sourceDirectory = typeof params.dir === "string" ? params.dir : undefined;
|
||||
const sourceDirectory = typeof params.dir === "string" ? params.dir : "";
|
||||
const displayName = typeof params.name === "string" ? params.name : undefined;
|
||||
const projectId = typeof params.projectId === "string" ? params.projectId : undefined;
|
||||
|
||||
if (!sourceDirectory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<NewWorkspaceScreen
|
||||
serverId={serverId}
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useLocalSearchParams } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import SettingsScreen from "@/screens/settings-screen";
|
||||
import { normalizeHostSectionSlug } from "@/utils/host-routes";
|
||||
import { type HostSectionSlug, isHostSectionSlug } from "@/utils/host-routes";
|
||||
|
||||
export default function SettingsHostSectionRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string; hostSection?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
|
||||
const rawSection = typeof params.hostSection === "string" ? params.hostSection : "";
|
||||
const section = normalizeHostSectionSlug(rawSection) ?? "connections";
|
||||
const section: HostSectionSlug = isHostSectionSlug(rawSection) ? rawSection : "connections";
|
||||
const view = useMemo(() => ({ kind: "host" as const, serverId, section }), [serverId, section]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,7 +3,6 @@ export {
|
||||
AssistantMarkdownCodeLink,
|
||||
AssistantMarkdownLink,
|
||||
} from "./link";
|
||||
export { type AssistantLinkPress, useAssistantLinkPress } from "./link-press-context";
|
||||
export {
|
||||
classifyAssistantFileLink,
|
||||
normalizeInlinePathTarget,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { TextProps } from "react-native";
|
||||
|
||||
// Carries a link's press handler down to the leaf text spans that render its
|
||||
// label. On iOS an assistant link is a nested UITextView span, and
|
||||
// react-native-uitextview only attaches onPress to the *string* children it
|
||||
// converts into RNUITextViewChild nodes (src/Text.tsx) — element children (the
|
||||
// MarkdownInheritedText spans markdown produces for link text) pass through
|
||||
// untouched, so an onPress placed on the wrapping span never reaches a tappable
|
||||
// native node. Threading the handler through context lets each leaf span hand
|
||||
// onPress to its own string children, where the native tap recognizer can find
|
||||
// it. Provided only on iOS (Android/web links tap fine via their own paths).
|
||||
export interface AssistantLinkPress {
|
||||
onPress: () => void;
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
}
|
||||
|
||||
const AssistantLinkPressContext = createContext<AssistantLinkPress | null>(null);
|
||||
|
||||
export const AssistantLinkPressProvider = AssistantLinkPressContext.Provider;
|
||||
|
||||
export function useAssistantLinkPress(): AssistantLinkPress | null {
|
||||
return useContext(AssistantLinkPressContext);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
|
||||
import {
|
||||
Platform,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
@@ -10,12 +9,9 @@ import {
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { AssistantLinkPressProvider, type AssistantLinkPress } from "./link-press-context";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { useAssistantFileLinkResolverContext } from "./provider";
|
||||
import type { AssistantFileLinkSource } from "./resolver";
|
||||
import { useFileLink } from "./use-file-link";
|
||||
@@ -23,16 +19,10 @@ import { useFileLink } from "./use-file-link";
|
||||
interface AssistantMarkdownLinkProps {
|
||||
source: AssistantFileLinkSource;
|
||||
style: StyleProp<TextStyle>;
|
||||
monoSurface?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AssistantMarkdownLink({
|
||||
source,
|
||||
style,
|
||||
monoSurface,
|
||||
children,
|
||||
}: AssistantMarkdownLinkProps) {
|
||||
export function AssistantMarkdownLink({ source, style, children }: AssistantMarkdownLinkProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source);
|
||||
const { configRef } = useAssistantFileLinkResolverContext();
|
||||
@@ -58,42 +48,13 @@ export function AssistantMarkdownLink({
|
||||
() => [style, hovered && { textDecorationLine: "underline" as const }],
|
||||
[style, hovered],
|
||||
);
|
||||
const linkPress = useMemo<AssistantLinkPress>(
|
||||
() => ({ onPress, accessibilityRole: "link" }),
|
||||
[onPress],
|
||||
);
|
||||
|
||||
if (isNative) {
|
||||
// Must be a MarkdownTextSpan, not a plain <Text>: on iOS the link renders
|
||||
// inside the paragraph's native UITextView, and a plain <Text> nested there
|
||||
// is not hoisted into a UITextViewChild, so its text is silently dropped
|
||||
// (the link disappears). The span composes correctly and stays selectable.
|
||||
//
|
||||
// Tap-to-open: react-native-uitextview only wires onPress onto the *string*
|
||||
// children it turns into RNUITextViewChild nodes — the element children that
|
||||
// markdown emits for link text pass through untouched, so an onPress placed
|
||||
// here never reaches a tappable native node. We thread it down through
|
||||
// AssistantLinkPressProvider so each leaf text span re-attaches it to its
|
||||
// own string children, where the native tap recognizer can find it. iOS
|
||||
// only: Android forwards onPress through nested <Text> already, and web uses
|
||||
// the <a> path below.
|
||||
const span = (
|
||||
<MarkdownTextSpan
|
||||
accessibilityRole="link"
|
||||
monoSurface={monoSurface}
|
||||
onPress={onPress}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</MarkdownTextSpan>
|
||||
);
|
||||
return (
|
||||
<FileLinkHoverTooltip filePath={tooltipPath}>
|
||||
{Platform.OS === "ios" ? (
|
||||
<AssistantLinkPressProvider value={linkPress}>{span}</AssistantLinkPressProvider>
|
||||
) : (
|
||||
span
|
||||
)}
|
||||
<Text accessibilityRole="link" onPress={onPress} style={style}>
|
||||
{children}
|
||||
</Text>
|
||||
</FileLinkHoverTooltip>
|
||||
);
|
||||
}
|
||||
@@ -111,9 +72,7 @@ export function AssistantMarkdownLink({
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
>
|
||||
<Text dataSet={monoSurface ? CODE_SURFACE_DATASET : undefined} style={hoveredTextStyle}>
|
||||
{children}
|
||||
</Text>
|
||||
<Text style={hoveredTextStyle}>{children}</Text>
|
||||
</Pressable>
|
||||
</a>
|
||||
);
|
||||
@@ -141,7 +100,7 @@ export function AssistantMarkdownCodeLink({
|
||||
[inheritedStyles, codeInlineStyle, linkStyle],
|
||||
);
|
||||
return (
|
||||
<AssistantMarkdownLink source={source} style={style} monoSurface>
|
||||
<AssistantMarkdownLink source={source} style={style}>
|
||||
{children}
|
||||
</AssistantMarkdownLink>
|
||||
);
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||
import { createDefaultLayout } from "@/stores/workspace-layout-store";
|
||||
import { openTabInLayoutFocused } from "@/stores/workspace-layout-actions";
|
||||
import { resolveBrowserNewTabRequest, type BrowserNewTabRequest } from ".";
|
||||
|
||||
function createLayoutWithBrowser(browserId: string): WorkspaceLayout {
|
||||
return openTabInLayoutFocused({
|
||||
layout: createDefaultLayout(),
|
||||
target: { kind: "browser", browserId },
|
||||
now: 1,
|
||||
}).layout;
|
||||
}
|
||||
|
||||
describe("browser new-tab requests", () => {
|
||||
it("accepts desktop requests from browser tabs in the current workspace", () => {
|
||||
const request = resolveBrowserNewTabRequest({
|
||||
payload: {
|
||||
sourceBrowserId: "browser-1",
|
||||
url: "https://example.com/target",
|
||||
},
|
||||
workspaceLayout: createLayoutWithBrowser("browser-1"),
|
||||
});
|
||||
|
||||
expect(request).toEqual<BrowserNewTabRequest>({
|
||||
sourceBrowserId: "browser-1",
|
||||
url: "https://example.com/target",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores desktop requests from another workspace", () => {
|
||||
const request = resolveBrowserNewTabRequest({
|
||||
payload: {
|
||||
sourceBrowserId: "browser-from-other-workspace",
|
||||
url: "https://example.com/target",
|
||||
},
|
||||
workspaceLayout: createLayoutWithBrowser("browser-1"),
|
||||
});
|
||||
|
||||
expect(request).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects unsupported desktop request URLs", () => {
|
||||
const request = resolveBrowserNewTabRequest({
|
||||
payload: {
|
||||
sourceBrowserId: "browser-1",
|
||||
url: "file:///etc/passwd",
|
||||
},
|
||||
workspaceLayout: createLayoutWithBrowser("browser-1"),
|
||||
});
|
||||
|
||||
expect(request).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { getDesktopHost, type DesktopBrowserNewTabRequestEvent } from "@/desktop/host";
|
||||
import { collectAllTabs, type WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||
import { getIsElectron } from "@/constants/platform";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
|
||||
export type BrowserNewTabRequest = DesktopBrowserNewTabRequestEvent;
|
||||
|
||||
function isAllowedBrowserNewTabUrl(value: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return (
|
||||
parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.href === "about:blank"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function readDesktopBrowserNewTabRequest(payload: unknown): BrowserNewTabRequest | null {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
const candidate = payload as Partial<BrowserNewTabRequest>;
|
||||
if (typeof candidate.sourceBrowserId !== "string" || !candidate.sourceBrowserId.trim()) {
|
||||
return null;
|
||||
}
|
||||
if (typeof candidate.url !== "string" || !isAllowedBrowserNewTabUrl(candidate.url)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sourceBrowserId: candidate.sourceBrowserId,
|
||||
url: candidate.url,
|
||||
};
|
||||
}
|
||||
|
||||
function workspaceContainsBrowser(input: {
|
||||
workspaceLayout: WorkspaceLayout | null | undefined;
|
||||
browserId: string;
|
||||
}): boolean {
|
||||
if (!input.workspaceLayout) {
|
||||
return false;
|
||||
}
|
||||
return collectAllTabs(input.workspaceLayout.root).some((tab) => {
|
||||
return tab.target.kind === "browser" && tab.target.browserId === input.browserId;
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveBrowserNewTabRequest(input: {
|
||||
payload: unknown;
|
||||
workspaceLayout: WorkspaceLayout | null | undefined;
|
||||
}): BrowserNewTabRequest | null {
|
||||
const request = readDesktopBrowserNewTabRequest(input.payload);
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!workspaceContainsBrowser({
|
||||
workspaceLayout: input.workspaceLayout,
|
||||
browserId: request.sourceBrowserId,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
export function useDesktopBrowserNewTabRequests(input: {
|
||||
enabled: boolean;
|
||||
workspaceLayout: WorkspaceLayout | null | undefined;
|
||||
openUrl: (url: string) => void;
|
||||
}): void {
|
||||
const handleNewTabRequest = useStableEvent((payload: unknown) => {
|
||||
const request = resolveBrowserNewTabRequest({
|
||||
payload,
|
||||
workspaceLayout: input.workspaceLayout,
|
||||
});
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
input.openUrl(request.url);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!input.enabled || !getIsElectron()) {
|
||||
return;
|
||||
}
|
||||
const unsubscribe = getDesktopHost()?.events?.on?.(
|
||||
"browser-new-tab-request",
|
||||
handleNewTabRequest,
|
||||
);
|
||||
if (typeof unsubscribe === "function") {
|
||||
return unsubscribe;
|
||||
}
|
||||
return () => {
|
||||
void unsubscribe?.then((dispose) => dispose());
|
||||
};
|
||||
}, [handleNewTabRequest, input.enabled]);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
||||
|
||||
describe("getCompactSheetSafeAreaPadding", () => {
|
||||
it("adds the bottom inset to compact sheet footers", () => {
|
||||
expect(
|
||||
getCompactSheetSafeAreaPadding({
|
||||
isCompact: true,
|
||||
hasFooter: true,
|
||||
baseContentPadding: 24,
|
||||
baseFooterPadding: 12,
|
||||
safeAreaBottom: 34,
|
||||
}),
|
||||
).toEqual({ footerPaddingBottom: 46 });
|
||||
});
|
||||
|
||||
it("adds the bottom inset to compact sheet content when there is no footer", () => {
|
||||
expect(
|
||||
getCompactSheetSafeAreaPadding({
|
||||
isCompact: true,
|
||||
hasFooter: false,
|
||||
baseContentPadding: 24,
|
||||
baseFooterPadding: 12,
|
||||
safeAreaBottom: 34,
|
||||
}),
|
||||
).toEqual({ contentPaddingBottom: 58 });
|
||||
});
|
||||
|
||||
it("does not inset desktop sheets", () => {
|
||||
expect(
|
||||
getCompactSheetSafeAreaPadding({
|
||||
isCompact: false,
|
||||
hasFooter: false,
|
||||
baseContentPadding: 24,
|
||||
baseFooterPadding: 12,
|
||||
safeAreaBottom: 34,
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
export interface CompactSheetSafeAreaPaddingInput {
|
||||
isCompact: boolean;
|
||||
hasFooter: boolean;
|
||||
baseContentPadding: number;
|
||||
baseFooterPadding: number;
|
||||
safeAreaBottom: number;
|
||||
}
|
||||
|
||||
export interface CompactSheetSafeAreaPadding {
|
||||
contentPaddingBottom?: number;
|
||||
footerPaddingBottom?: number;
|
||||
}
|
||||
|
||||
export function getCompactSheetSafeAreaPadding({
|
||||
isCompact,
|
||||
hasFooter,
|
||||
baseContentPadding,
|
||||
baseFooterPadding,
|
||||
safeAreaBottom,
|
||||
}: CompactSheetSafeAreaPaddingInput): CompactSheetSafeAreaPadding {
|
||||
if (!isCompact || safeAreaBottom <= 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (hasFooter) {
|
||||
return { footerPaddingBottom: baseFooterPadding + safeAreaBottom };
|
||||
}
|
||||
|
||||
return { contentPaddingBottom: baseContentPadding + safeAreaBottom };
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
BottomSheetTextInput,
|
||||
type BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { ArrowLeft, Search, X } from "lucide-react-native";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
@@ -20,9 +19,7 @@ import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
// Horizontal indent token shared by the sheet header (title, back arrow,
|
||||
// leading icon, search input icon) and any row primitive rendered inside the
|
||||
@@ -233,14 +230,14 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
() => [
|
||||
style,
|
||||
{
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderTopLeftRadius: theme.borderRadius.xl,
|
||||
borderTopRightRadius: theme.borderRadius.xl,
|
||||
},
|
||||
],
|
||||
[style, theme.colors.surface0, theme.borderRadius],
|
||||
[style, theme.colors.surface1, theme.borderRadius.xl],
|
||||
);
|
||||
return <Animated.View pointerEvents="none" style={combinedStyle} />;
|
||||
return <View style={combinedStyle} />;
|
||||
}
|
||||
|
||||
export type AdaptiveTextInputProps = TextInputProps & {
|
||||
@@ -445,7 +442,6 @@ export interface AdaptiveModalSheetProps {
|
||||
/** When provided, wraps the card content in a FileDropZone. */
|
||||
onFilesDropped?: (files: ImageAttachment[]) => void;
|
||||
scrollable?: boolean;
|
||||
presentation?: "push" | "replace";
|
||||
}
|
||||
|
||||
export function AdaptiveModalSheet({
|
||||
@@ -459,53 +455,13 @@ export function AdaptiveModalSheet({
|
||||
desktopMaxWidth,
|
||||
onFilesDropped,
|
||||
scrollable = true,
|
||||
presentation,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const insets = useSafeAreaInsets();
|
||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||
const compactSafeAreaPadding = useMemo(
|
||||
() =>
|
||||
getCompactSheetSafeAreaPadding({
|
||||
isCompact: isMobile,
|
||||
hasFooter: Boolean(footer),
|
||||
baseContentPadding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
||||
baseFooterPadding: theme.spacing[3],
|
||||
safeAreaBottom: insets.bottom,
|
||||
}),
|
||||
[footer, insets.bottom, isMobile, theme.spacing],
|
||||
);
|
||||
const bottomSheetContentStyle = useMemo(
|
||||
() => [
|
||||
styles.bottomSheetContent,
|
||||
compactSafeAreaPadding.contentPaddingBottom != null
|
||||
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
|
||||
: null,
|
||||
],
|
||||
[compactSafeAreaPadding.contentPaddingBottom],
|
||||
);
|
||||
const bottomSheetStaticContentStyle = useMemo(
|
||||
() => [
|
||||
styles.bottomSheetStaticContent,
|
||||
compactSafeAreaPadding.contentPaddingBottom != null
|
||||
? { paddingBottom: compactSafeAreaPadding.contentPaddingBottom }
|
||||
: null,
|
||||
],
|
||||
[compactSafeAreaPadding.contentPaddingBottom],
|
||||
);
|
||||
const footerStyle = useMemo(
|
||||
() => [
|
||||
styles.footer,
|
||||
compactSafeAreaPadding.footerPaddingBottom != null
|
||||
? { paddingBottom: compactSafeAreaPadding.footerPaddingBottom }
|
||||
: null,
|
||||
],
|
||||
[compactSafeAreaPadding.footerPaddingBottom],
|
||||
);
|
||||
const handleIndicatorStyle = useMemo(
|
||||
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
|
||||
[theme.colors.palette.zinc],
|
||||
() => ({ backgroundColor: theme.colors.surface2 }),
|
||||
[theme.colors.surface2],
|
||||
);
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible,
|
||||
@@ -546,21 +502,20 @@ export function AdaptiveModalSheet({
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
accessible={false}
|
||||
presentation={presentation}
|
||||
>
|
||||
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
|
||||
{scrollable ? (
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={bottomSheetContentStyle}
|
||||
contentContainerStyle={styles.bottomSheetContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</BottomSheetScrollView>
|
||||
) : (
|
||||
<View style={bottomSheetStaticContentStyle}>{children}</View>
|
||||
<View style={styles.bottomSheetStaticContent}>{children}</View>
|
||||
)}
|
||||
{footer ? <View style={footerStyle}>{footer}</View> : null}
|
||||
{footer ? <View style={styles.footer}>{footer}</View> : null}
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
@@ -579,7 +534,7 @@ export function AdaptiveModalSheet({
|
||||
) : (
|
||||
<View style={styles.desktopStaticContent}>{children}</View>
|
||||
)}
|
||||
{footer ? <View style={footerStyle}>{footer}</View> : null}
|
||||
{footer ? <View style={styles.footer}>{footer}</View> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -324,7 +324,7 @@ export function AgentList({
|
||||
|
||||
const handleAgentLongPress = useCallback(
|
||||
(agent: AggregatedAgent) => {
|
||||
const isRunning = agent.status === "running";
|
||||
const isRunning = agent.status === "running" || agent.status === "initializing";
|
||||
if (isRunning) {
|
||||
setActionAgent(agent);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
buildAgentStreamSearchModel,
|
||||
findAgentStreamSearchMatches,
|
||||
} from "./agent-stream-search-model";
|
||||
|
||||
function timestamp(seed: number): Date {
|
||||
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
|
||||
}
|
||||
|
||||
function userMessage(id: string, text: string, seed = 1): StreamItem {
|
||||
return {
|
||||
kind: "user_message",
|
||||
id,
|
||||
text,
|
||||
timestamp: timestamp(seed),
|
||||
};
|
||||
}
|
||||
|
||||
function assistantMessage(id: string, text: string, seed = 1): StreamItem {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
id,
|
||||
text,
|
||||
timestamp: timestamp(seed),
|
||||
};
|
||||
}
|
||||
|
||||
function thought(id: string, text: string, seed = 1): StreamItem {
|
||||
return {
|
||||
kind: "thought",
|
||||
id,
|
||||
text,
|
||||
status: "ready",
|
||||
timestamp: timestamp(seed),
|
||||
};
|
||||
}
|
||||
|
||||
function activityLog(id: string, message: string, seed = 1): StreamItem {
|
||||
return {
|
||||
kind: "activity_log",
|
||||
id,
|
||||
activityType: "info",
|
||||
message,
|
||||
metadata: { hidden: "metadata is not searched" },
|
||||
timestamp: timestamp(seed),
|
||||
};
|
||||
}
|
||||
|
||||
function todoList(id: string, seed = 1): StreamItem {
|
||||
return {
|
||||
kind: "todo_list",
|
||||
id,
|
||||
provider: "codex",
|
||||
items: [
|
||||
{ text: "Write the red test", completed: true },
|
||||
{ text: "Make search green", completed: false },
|
||||
],
|
||||
timestamp: timestamp(seed),
|
||||
};
|
||||
}
|
||||
|
||||
function getSearchableText(item: StreamItem): string {
|
||||
const model = buildAgentStreamSearchModel({
|
||||
streamItems: [item],
|
||||
streamHead: [],
|
||||
platform: "web",
|
||||
isMobileBreakpoint: true,
|
||||
});
|
||||
return model.entries[0]?.text ?? "";
|
||||
}
|
||||
|
||||
describe("buildAgentStreamSearchModel", () => {
|
||||
it("indexes user and assistant message text", () => {
|
||||
expect(getSearchableText(userMessage("u1", "user text"))).toBe("user text");
|
||||
expect(getSearchableText(assistantMessage("a1", "assistant text"))).toBe("assistant text");
|
||||
});
|
||||
|
||||
it("excludes non-message rows that do not render find highlights", () => {
|
||||
expect(getSearchableText(thought("t1", "thought text"))).toBe("");
|
||||
expect(getSearchableText(activityLog("l1", "activity text"))).toBe("");
|
||||
expect(getSearchableText(todoList("todo"))).toBe("");
|
||||
});
|
||||
|
||||
it("excludes tool call rows from the search index", () => {
|
||||
const toolCall: StreamItem = {
|
||||
kind: "tool_call",
|
||||
id: "shell",
|
||||
timestamp: timestamp(1),
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
provider: "codex",
|
||||
callId: "call-shell",
|
||||
name: "exec_command",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "npm run typecheck",
|
||||
output: "internal output should stay out",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(getSearchableText(toolCall)).toBe("");
|
||||
});
|
||||
|
||||
it("orders virtualized history, mounted history, live head, and optimistic items deterministically", () => {
|
||||
const committed: StreamItem[] = [];
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
committed.push(userMessage(`u${index}`, `history ${index}`, index));
|
||||
}
|
||||
const optimistic = userMessage("optimistic", "draft message", 65);
|
||||
const liveHead = [assistantMessage("live", "live answer", 66)];
|
||||
|
||||
const model = buildAgentStreamSearchModel({
|
||||
streamItems: committed,
|
||||
optimisticItems: [optimistic],
|
||||
streamHead: liveHead,
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(model.entries.at(0)?.item.id).toBe("optimistic");
|
||||
expect(model.entries.map((entry) => entry.source)).toEqual([
|
||||
...Array.from(
|
||||
{ length: model.segments.historyVirtualized.length },
|
||||
() => "historyVirtualized",
|
||||
),
|
||||
...Array.from({ length: model.segments.historyMounted.length }, () => "historyMounted"),
|
||||
"liveHead",
|
||||
]);
|
||||
expect(model.entries.at(-1)?.item.id).toBe("live");
|
||||
});
|
||||
|
||||
it("does not duplicate an optimistic item once committed history has the same id", () => {
|
||||
const committed = [userMessage("u1", "committed draft")];
|
||||
const model = buildAgentStreamSearchModel({
|
||||
streamItems: committed,
|
||||
optimisticItems: [userMessage("u1", "optimistic draft")],
|
||||
streamHead: [],
|
||||
platform: "web",
|
||||
isMobileBreakpoint: true,
|
||||
});
|
||||
|
||||
expect(model.entries.map((entry) => entry.text)).toEqual(["committed draft"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAgentStreamSearchMatches", () => {
|
||||
it("returns stable match ids from item identity and local occurrence data", () => {
|
||||
const model = buildAgentStreamSearchModel({
|
||||
streamItems: [assistantMessage("a1", "Alpha alpha beta")],
|
||||
streamHead: [assistantMessage("h1", "alpha live")],
|
||||
platform: "web",
|
||||
isMobileBreakpoint: true,
|
||||
});
|
||||
|
||||
const matches = findAgentStreamSearchMatches({
|
||||
model,
|
||||
query: "alpha",
|
||||
});
|
||||
|
||||
expect(matches.map((match) => match.id)).toEqual([
|
||||
"a1:text:0:0:5",
|
||||
"a1:text:1:6:11",
|
||||
"h1:text:0:0:5",
|
||||
]);
|
||||
expect(matches.map((match) => match.entry.item.id)).toEqual(["a1", "a1", "h1"]);
|
||||
});
|
||||
|
||||
it("skips fenced code blocks while preserving message offsets for highlights", () => {
|
||||
const model = buildAgentStreamSearchModel({
|
||||
streamItems: [assistantMessage("a1", "before alpha\n```\nalpha\n```\nafter alpha")],
|
||||
streamHead: [],
|
||||
platform: "web",
|
||||
isMobileBreakpoint: true,
|
||||
});
|
||||
|
||||
const matches = findAgentStreamSearchMatches({
|
||||
model,
|
||||
query: "alpha",
|
||||
});
|
||||
|
||||
expect(matches.map((match) => match.id)).toEqual(["a1:text:0:7:12", "a1:text:0:33:38"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
findMountedWindowStart,
|
||||
getWebMountedRecentStreamItems,
|
||||
getWebPartialVirtualizationThreshold,
|
||||
} from "@/agent-stream/web-virtualization";
|
||||
|
||||
type AgentStreamSearchSource = "historyVirtualized" | "historyMounted" | "liveHead";
|
||||
|
||||
interface AgentStreamSearchTextSegment {
|
||||
key: string;
|
||||
text: string;
|
||||
startOffset: number;
|
||||
}
|
||||
|
||||
interface AgentStreamSearchEntry {
|
||||
item: StreamItem;
|
||||
source: AgentStreamSearchSource;
|
||||
index: number;
|
||||
text: string;
|
||||
segments: AgentStreamSearchTextSegment[];
|
||||
}
|
||||
|
||||
export interface AgentStreamSearchMatch {
|
||||
id: string;
|
||||
entry: AgentStreamSearchEntry;
|
||||
segmentKey: string;
|
||||
occurrenceIndex: number;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface AgentStreamSearchModel {
|
||||
entries: AgentStreamSearchEntry[];
|
||||
segments: {
|
||||
historyVirtualized: AgentStreamSearchEntry[];
|
||||
historyMounted: AgentStreamSearchEntry[];
|
||||
liveHead: AgentStreamSearchEntry[];
|
||||
};
|
||||
}
|
||||
|
||||
interface BuildAgentStreamSearchModelInput {
|
||||
platform: "web" | "native";
|
||||
isMobileBreakpoint: boolean;
|
||||
streamItems: StreamItem[];
|
||||
streamHead: StreamItem[];
|
||||
optimisticItems?: StreamItem[];
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
interface FindAgentStreamSearchMatchesInput {
|
||||
model: AgentStreamSearchModel;
|
||||
query: string;
|
||||
}
|
||||
|
||||
function getFenceDelimiter(line: string): string | null {
|
||||
const match = /^( {0,3})(`{3,}|~{3,})/.exec(line);
|
||||
return match?.[2] ?? null;
|
||||
}
|
||||
|
||||
function getMessageSearchableSegments(text: string): AgentStreamSearchTextSegment[] {
|
||||
const segments: AgentStreamSearchTextSegment[] = [];
|
||||
let activeFenceCharacter: "`" | "~" | null = null;
|
||||
let activeFenceLength = 0;
|
||||
let currentText = "";
|
||||
let currentStartOffset = 0;
|
||||
let offset = 0;
|
||||
|
||||
const flush = () => {
|
||||
if (currentText.length > 0) {
|
||||
segments.push({ key: "text", text: currentText, startOffset: currentStartOffset });
|
||||
currentText = "";
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of text.split("\n")) {
|
||||
const lineWithBreak = offset + line.length < text.length ? `${line}\n` : line;
|
||||
const fenceDelimiter = getFenceDelimiter(line);
|
||||
const isClosingFence =
|
||||
activeFenceCharacter &&
|
||||
fenceDelimiter?.[0] === activeFenceCharacter &&
|
||||
fenceDelimiter.length >= activeFenceLength;
|
||||
const isOpeningFence = !activeFenceCharacter && fenceDelimiter;
|
||||
const isIndentedCode = !activeFenceCharacter && (/^( {4,}|\t)/.test(line) || line === " ");
|
||||
|
||||
if (isOpeningFence || activeFenceCharacter || isIndentedCode) {
|
||||
flush();
|
||||
} else {
|
||||
if (currentText.length === 0) {
|
||||
currentStartOffset = offset;
|
||||
}
|
||||
currentText += lineWithBreak;
|
||||
}
|
||||
|
||||
if (isOpeningFence) {
|
||||
activeFenceCharacter = fenceDelimiter[0] as "`" | "~";
|
||||
activeFenceLength = fenceDelimiter.length;
|
||||
} else if (isClosingFence) {
|
||||
activeFenceCharacter = null;
|
||||
activeFenceLength = 0;
|
||||
}
|
||||
|
||||
offset += lineWithBreak.length;
|
||||
}
|
||||
|
||||
flush();
|
||||
return segments;
|
||||
}
|
||||
|
||||
function getAgentStreamItemSearchableSegments(item: StreamItem): AgentStreamSearchTextSegment[] {
|
||||
switch (item.kind) {
|
||||
case "user_message":
|
||||
case "assistant_message":
|
||||
return item.text ? getMessageSearchableSegments(item.text) : [];
|
||||
case "thought":
|
||||
case "activity_log":
|
||||
case "todo_list":
|
||||
case "tool_call":
|
||||
case "compaction":
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function mergeOptimisticItems(input: {
|
||||
streamItems: StreamItem[];
|
||||
optimisticItems: StreamItem[] | undefined;
|
||||
}): StreamItem[] {
|
||||
if (!input.optimisticItems || input.optimisticItems.length === 0) {
|
||||
return input.streamItems;
|
||||
}
|
||||
const committedIds = new Set(input.streamItems.map((item) => item.id));
|
||||
const pendingOptimisticItems = input.optimisticItems.filter((item) => !committedIds.has(item.id));
|
||||
if (pendingOptimisticItems.length === 0) {
|
||||
return input.streamItems;
|
||||
}
|
||||
return [...pendingOptimisticItems, ...input.streamItems];
|
||||
}
|
||||
|
||||
function buildEntries(input: {
|
||||
items: StreamItem[];
|
||||
source: AgentStreamSearchSource;
|
||||
startIndex: number;
|
||||
cwd: string | undefined;
|
||||
}): AgentStreamSearchEntry[] {
|
||||
return input.items.map((item, offset) => {
|
||||
const segments = getAgentStreamItemSearchableSegments(item);
|
||||
return {
|
||||
item,
|
||||
source: input.source,
|
||||
index: input.startIndex + offset,
|
||||
text: segments.map((segment) => segment.text).join("\n"),
|
||||
segments,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function orderStreamItems(input: {
|
||||
items: StreamItem[];
|
||||
platform: "web" | "native";
|
||||
}): StreamItem[] {
|
||||
return input.platform === "native" ? [...input.items].toReversed() : input.items;
|
||||
}
|
||||
|
||||
function splitOrderedHistory(input: {
|
||||
orderedTail: StreamItem[];
|
||||
platform: "web" | "native";
|
||||
isMobileBreakpoint: boolean;
|
||||
}): {
|
||||
historyVirtualizedItems: StreamItem[];
|
||||
historyMountedItems: StreamItem[];
|
||||
} {
|
||||
const shouldSplitHistory =
|
||||
input.platform === "web" &&
|
||||
!input.isMobileBreakpoint &&
|
||||
input.orderedTail.length > getWebPartialVirtualizationThreshold();
|
||||
if (!shouldSplitHistory) {
|
||||
return {
|
||||
historyVirtualizedItems: [],
|
||||
historyMountedItems: input.orderedTail,
|
||||
};
|
||||
}
|
||||
const mountedWindowStart = findMountedWindowStart({
|
||||
items: input.orderedTail,
|
||||
minMountedCount: getWebMountedRecentStreamItems(),
|
||||
});
|
||||
return {
|
||||
historyVirtualizedItems: input.orderedTail.slice(0, mountedWindowStart),
|
||||
historyMountedItems: input.orderedTail.slice(mountedWindowStart),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAgentStreamSearchModel(
|
||||
input: BuildAgentStreamSearchModelInput,
|
||||
): AgentStreamSearchModel {
|
||||
const tail = mergeOptimisticItems({
|
||||
streamItems: input.streamItems,
|
||||
optimisticItems: input.optimisticItems,
|
||||
});
|
||||
const orderedTail = orderStreamItems({
|
||||
items: tail,
|
||||
platform: input.platform,
|
||||
});
|
||||
const orderedHead = orderStreamItems({
|
||||
items: input.streamHead,
|
||||
platform: input.platform,
|
||||
});
|
||||
const splitHistory = splitOrderedHistory({
|
||||
orderedTail,
|
||||
platform: input.platform,
|
||||
isMobileBreakpoint: input.isMobileBreakpoint,
|
||||
});
|
||||
const historyVirtualized = buildEntries({
|
||||
items: splitHistory.historyVirtualizedItems,
|
||||
source: "historyVirtualized",
|
||||
startIndex: 0,
|
||||
cwd: input.cwd,
|
||||
});
|
||||
const historyMounted = buildEntries({
|
||||
items: splitHistory.historyMountedItems,
|
||||
source: "historyMounted",
|
||||
startIndex: historyVirtualized.length,
|
||||
cwd: input.cwd,
|
||||
});
|
||||
const liveHead = buildEntries({
|
||||
items: orderedHead,
|
||||
source: "liveHead",
|
||||
startIndex: historyVirtualized.length + historyMounted.length,
|
||||
cwd: input.cwd,
|
||||
});
|
||||
return {
|
||||
entries: [...historyVirtualized, ...historyMounted, ...liveHead],
|
||||
segments: {
|
||||
historyVirtualized,
|
||||
historyMounted,
|
||||
liveHead,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function findAgentStreamSearchMatches(
|
||||
input: FindAgentStreamSearchMatchesInput,
|
||||
): AgentStreamSearchMatch[] {
|
||||
const normalizedQuery = input.query.toLocaleLowerCase();
|
||||
if (!normalizedQuery) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matches: AgentStreamSearchMatch[] = [];
|
||||
for (const entry of input.model.entries) {
|
||||
for (const segment of entry.segments) {
|
||||
const normalizedText = segment.text.toLocaleLowerCase();
|
||||
let occurrenceIndex = 0;
|
||||
let fromIndex = 0;
|
||||
while (fromIndex <= normalizedText.length) {
|
||||
const start = normalizedText.indexOf(normalizedQuery, fromIndex);
|
||||
if (start < 0) {
|
||||
break;
|
||||
}
|
||||
const end = start + input.query.length;
|
||||
const absoluteStart = segment.startOffset + start;
|
||||
const absoluteEnd = segment.startOffset + end;
|
||||
matches.push({
|
||||
id: `${entry.item.id}:${segment.key}:${occurrenceIndex}:${absoluteStart}:${absoluteEnd}`,
|
||||
entry,
|
||||
segmentKey: segment.key,
|
||||
occurrenceIndex,
|
||||
start: absoluteStart,
|
||||
end: absoluteEnd,
|
||||
});
|
||||
occurrenceIndex += 1;
|
||||
fromIndex = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Fragment, type ReactNode } from "react";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
interface AppearanceStyleBoundaryProps {
|
||||
appearanceKey?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function AppearanceStyleBoundaryBase({ appearanceKey, children }: AppearanceStyleBoundaryProps) {
|
||||
return <Fragment key={appearanceKey}>{children}</Fragment>;
|
||||
}
|
||||
|
||||
const appearanceStyleBoundaryMapping = (theme: Theme): Partial<AppearanceStyleBoundaryProps> => ({
|
||||
appearanceKey: [
|
||||
theme.fontFamily.ui,
|
||||
theme.fontFamily.mono,
|
||||
theme.fontSize.xs,
|
||||
theme.fontSize.sm,
|
||||
theme.fontSize.base,
|
||||
theme.fontSize.lg,
|
||||
theme.fontSize.xl,
|
||||
theme.fontSize["2xl"],
|
||||
theme.fontSize["3xl"],
|
||||
theme.fontSize["4xl"],
|
||||
theme.fontSize.code,
|
||||
theme.lineHeight.diff,
|
||||
theme.colors.foreground,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.colors.mutedForeground,
|
||||
theme.colors.surface1,
|
||||
theme.colors.surface2,
|
||||
theme.colors.border,
|
||||
theme.colors.accentBright,
|
||||
theme.colors.syntax.keyword,
|
||||
theme.colors.syntax.comment,
|
||||
theme.colors.syntax.string,
|
||||
theme.colors.syntax.number,
|
||||
theme.colors.syntax.literal,
|
||||
theme.colors.syntax.function,
|
||||
theme.colors.syntax.definition,
|
||||
theme.colors.syntax.class,
|
||||
theme.colors.syntax.type,
|
||||
theme.colors.syntax.tag,
|
||||
theme.colors.syntax.attribute,
|
||||
theme.colors.syntax.property,
|
||||
theme.colors.syntax.variable,
|
||||
theme.colors.syntax.operator,
|
||||
theme.colors.syntax.punctuation,
|
||||
theme.colors.syntax.regexp,
|
||||
theme.colors.syntax.escape,
|
||||
theme.colors.syntax.meta,
|
||||
theme.colors.syntax.heading,
|
||||
theme.colors.syntax.link,
|
||||
].join("\u0000"),
|
||||
});
|
||||
|
||||
const ThemedAppearanceStyleBoundary = withUnistyles(AppearanceStyleBoundaryBase);
|
||||
|
||||
export function AppearanceStyleBoundary({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ThemedAppearanceStyleBoundary uniProps={appearanceStyleBoundaryMapping}>
|
||||
{children}
|
||||
</ThemedAppearanceStyleBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* @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 { BrowserPane } from "@/components/browser-pane.electron";
|
||||
import {
|
||||
PaneFocusProvider,
|
||||
PaneProvider,
|
||||
createPaneFocusContextValue,
|
||||
type PaneContextValue,
|
||||
} from "@/panels/pane-context";
|
||||
import {
|
||||
createPaneFindPaneId,
|
||||
handlePaneFindKeyboardAction,
|
||||
setActivePaneFindPaneId,
|
||||
} from "@/panels/pane-find-registry";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
|
||||
const updateBrowser = vi.fn();
|
||||
let browserState = {
|
||||
browsersById: {
|
||||
"browser-a": {
|
||||
id: "browser-a",
|
||||
url: "https://example.com",
|
||||
title: "",
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
lastError: null,
|
||||
},
|
||||
},
|
||||
updateBrowser,
|
||||
};
|
||||
|
||||
const { theme } = vi.hoisted(() => ({
|
||||
theme: {
|
||||
borderRadius: { md: 6 },
|
||||
colors: {
|
||||
accent: "#3b82f6",
|
||||
border: "#333",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
palette: { red: { 500: "#ef4444" } },
|
||||
surface0: "#111",
|
||||
surface1: "#222",
|
||||
surface2: "#333",
|
||||
},
|
||||
fontSize: { sm: 13, xs: 11 },
|
||||
spacing: { 1: 4, 2: 8 },
|
||||
},
|
||||
}));
|
||||
|
||||
const desktopBridge = vi.hoisted(() => {
|
||||
const foundInPageListeners = new Set<(result: unknown) => void>();
|
||||
return {
|
||||
foundInPageListeners,
|
||||
findInPage: vi.fn<(browserId: string, text: string, options?: unknown) => number>(),
|
||||
setActivePane: vi.fn<(browserId: string | null) => Promise<void>>(),
|
||||
stopFindInPage: vi.fn<(browserId: string, action: string) => void>(),
|
||||
onFoundInPage: vi.fn((_browserId: string, listener: (result: unknown) => void) => {
|
||||
const scopedListener = (result: unknown) => listener(result);
|
||||
foundInPageListeners.add(scopedListener);
|
||||
return () => {
|
||||
foundInPageListeners.delete(scopedListener);
|
||||
};
|
||||
}),
|
||||
eventsOn: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const createIcon = (name: string) =>
|
||||
function Icon() {
|
||||
return React.createElement("span", { "data-icon": name });
|
||||
};
|
||||
|
||||
return {
|
||||
ArrowLeft: createIcon("ArrowLeft"),
|
||||
ArrowRight: createIcon("ArrowRight"),
|
||||
ChevronDown: createIcon("ChevronDown"),
|
||||
ChevronUp: createIcon("ChevronUp"),
|
||||
MousePointer2: createIcon("MousePointer2"),
|
||||
RotateCw: createIcon("RotateCw"),
|
||||
X: createIcon("X"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native", () => {
|
||||
const MockView = ({
|
||||
children,
|
||||
testID,
|
||||
style,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
testID?: string;
|
||||
style?: unknown;
|
||||
}) => React.createElement("div", { "data-testid": testID, style }, children);
|
||||
const MockText = ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("span", null, children);
|
||||
const MockPressable = ({
|
||||
children,
|
||||
disabled,
|
||||
onPress,
|
||||
testID,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onPress?: () => void;
|
||||
testID?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
"data-testid": testID,
|
||||
disabled,
|
||||
onClick: () => {
|
||||
if (!disabled) onPress?.();
|
||||
},
|
||||
type: "button",
|
||||
},
|
||||
children,
|
||||
);
|
||||
const MockTextInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
{
|
||||
value?: string;
|
||||
onChangeText?: (text: string) => void;
|
||||
onFocus?: () => void;
|
||||
onKeyPress?: (event: {
|
||||
nativeEvent: { key: string; shiftKey?: boolean };
|
||||
preventDefault: () => void;
|
||||
}) => void;
|
||||
onSubmitEditing?: () => void;
|
||||
testID?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
>(function TextInput(
|
||||
{ value, onChangeText, onFocus, onKeyPress, onSubmitEditing, testID, placeholder },
|
||||
ref,
|
||||
) {
|
||||
return React.createElement("input", {
|
||||
"data-testid": testID,
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChangeText?.(event.currentTarget.value),
|
||||
onFocus,
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
onKeyPress?.({
|
||||
nativeEvent: { key: event.key, shiftKey: event.shiftKey },
|
||||
preventDefault: () => event.preventDefault(),
|
||||
});
|
||||
if (event.key === "Enter") {
|
||||
onSubmitEditing?.();
|
||||
}
|
||||
},
|
||||
placeholder,
|
||||
ref,
|
||||
value: value ?? "",
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
Platform: {
|
||||
OS: "web",
|
||||
select: (options: Record<string, unknown>) => options.web ?? options.default,
|
||||
},
|
||||
Pressable: MockPressable,
|
||||
Text: MockText,
|
||||
TextInput: MockTextInput,
|
||||
View: MockView,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
hairlineWidth: 1,
|
||||
create: (factory: unknown) =>
|
||||
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
||||
},
|
||||
useUnistyles: () => ({ theme }),
|
||||
}));
|
||||
|
||||
vi.mock("@/attachments/workspace-attachments-store", () => ({
|
||||
buildWorkspaceAttachmentScopeKey: () => "scope-a",
|
||||
useWorkspaceAttachments: () => [],
|
||||
useWorkspaceAttachmentsStore: (
|
||||
selector: (state: { setWorkspaceAttachments: () => void }) => unknown,
|
||||
) => selector({ setWorkspaceAttachments: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/host", () => ({
|
||||
getDesktopHost: () => ({
|
||||
browser: {
|
||||
findInPage: desktopBridge.findInPage,
|
||||
onFoundInPage: desktopBridge.onFoundInPage,
|
||||
setActivePane: desktopBridge.setActivePane,
|
||||
stopFindInPage: desktopBridge.stopFindInPage,
|
||||
},
|
||||
events: { on: desktopBridge.eventsOn },
|
||||
}),
|
||||
isElectronRuntime: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/layout", () => ({
|
||||
WORKSPACE_SECONDARY_HEADER_HEIGHT: 36,
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/browser-store", () => ({
|
||||
normalizeWorkspaceBrowserUrl: (url: string) => url,
|
||||
useBrowserStore: (selector: (state: typeof browserState) => unknown) => selector(browserState),
|
||||
}));
|
||||
|
||||
type FakeWebview = HTMLDivElement & {
|
||||
getURL: ReturnType<typeof vi.fn<() => string>>;
|
||||
canGoBack: ReturnType<typeof vi.fn<() => boolean>>;
|
||||
canGoForward: ReturnType<typeof vi.fn<() => boolean>>;
|
||||
reload: ReturnType<typeof vi.fn<() => void>>;
|
||||
stop: ReturnType<typeof vi.fn<() => void>>;
|
||||
};
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
let webview: FakeWebview | null = null;
|
||||
let nextRequestId = 1;
|
||||
let restoreCreateElement: (() => void) | null = null;
|
||||
|
||||
const paneInstanceId = createPaneFindPaneId({
|
||||
serverId: "server-a",
|
||||
workspaceId: "workspace-a",
|
||||
paneId: "pane-a",
|
||||
});
|
||||
|
||||
const paneContextValue: PaneContextValue = {
|
||||
serverId: "server-a",
|
||||
workspaceId: "workspace-a",
|
||||
paneInstanceId,
|
||||
tabId: "browser_browser-a",
|
||||
target: { kind: "browser", browserId: "browser-a" },
|
||||
openTab: vi.fn(),
|
||||
closeCurrentTab: vi.fn(),
|
||||
retargetCurrentTab: vi.fn(),
|
||||
openFileInWorkspace: vi.fn(),
|
||||
openImportSheet: vi.fn(),
|
||||
};
|
||||
|
||||
function installWebviewElementFactory(): void {
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
const createElementSpy = vi.spyOn(document, "createElement");
|
||||
const createElement = ((tagName: string, options?: ElementCreationOptions) => {
|
||||
if (tagName.toLowerCase() !== "webview") {
|
||||
return originalCreateElement(tagName, options);
|
||||
}
|
||||
const element = originalCreateElement("div") as FakeWebview;
|
||||
element.getURL = vi.fn(() => "https://example.com");
|
||||
element.canGoBack = vi.fn(() => false);
|
||||
element.canGoForward = vi.fn(() => false);
|
||||
element.reload = vi.fn();
|
||||
element.stop = vi.fn();
|
||||
webview = element;
|
||||
return element;
|
||||
}) as typeof document.createElement;
|
||||
createElementSpy.mockImplementation(createElement);
|
||||
restoreCreateElement = () => createElementSpy.mockRestore();
|
||||
}
|
||||
|
||||
function renderBrowserPane(input?: { isInteractive?: boolean }): void {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<PaneProvider value={paneContextValue}>
|
||||
<PaneFocusProvider
|
||||
value={createPaneFocusContextValue({
|
||||
isPaneFocused: input?.isInteractive ?? true,
|
||||
isWorkspaceFocused: true,
|
||||
})}
|
||||
>
|
||||
<BrowserPane
|
||||
browserId="browser-a"
|
||||
serverId="server-a"
|
||||
workspaceId="workspace-a"
|
||||
cwd="/repo"
|
||||
isInteractive={input?.isInteractive ?? true}
|
||||
/>
|
||||
</PaneFocusProvider>
|
||||
</PaneProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function openFind(): void {
|
||||
act(() => {
|
||||
setActivePaneFindPaneId(paneInstanceId);
|
||||
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
|
||||
});
|
||||
}
|
||||
|
||||
function markWebviewDomReady(): void {
|
||||
act(() => {
|
||||
webview?.dispatchEvent(new Event("dom-ready"));
|
||||
});
|
||||
}
|
||||
|
||||
function inputElement(): HTMLInputElement {
|
||||
const input = container?.querySelector('[data-testid="pane-find-input"]');
|
||||
expect(input).toBeInstanceOf(HTMLInputElement);
|
||||
return input as HTMLInputElement;
|
||||
}
|
||||
|
||||
function changeInput(value: string): void {
|
||||
const input = inputElement();
|
||||
act(() => {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
valueSetter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function pressKey(key: string, shiftKey = false): void {
|
||||
const input = inputElement();
|
||||
act(() => {
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key, shiftKey, bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function click(testId: string): void {
|
||||
const element = container?.querySelector(`[data-testid="${testId}"]`);
|
||||
expect(element).toBeInstanceOf(HTMLElement);
|
||||
act(() => {
|
||||
element?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function dispatchFoundInPage(result: {
|
||||
requestId?: number;
|
||||
activeMatchOrdinal: number;
|
||||
matches: number;
|
||||
}): void {
|
||||
act(() => {
|
||||
for (const listener of desktopBridge.foundInPageListeners) {
|
||||
listener(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
updateBrowser.mockClear();
|
||||
desktopBridge.findInPage.mockImplementation(() => nextRequestId++);
|
||||
desktopBridge.findInPage.mockClear();
|
||||
desktopBridge.onFoundInPage.mockClear();
|
||||
desktopBridge.setActivePane.mockClear();
|
||||
desktopBridge.stopFindInPage.mockClear();
|
||||
desktopBridge.eventsOn.mockClear();
|
||||
desktopBridge.foundInPageListeners.clear();
|
||||
browserState = {
|
||||
...browserState,
|
||||
browsersById: {
|
||||
"browser-a": {
|
||||
id: "browser-a",
|
||||
url: "https://example.com",
|
||||
title: "",
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
isLoading: false,
|
||||
lastError: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
nextRequestId = 1;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
installWebviewElementFactory();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
webview = null;
|
||||
restoreCreateElement?.();
|
||||
restoreCreateElement = null;
|
||||
});
|
||||
|
||||
describe("BrowserPane Electron find", () => {
|
||||
it("registers browser find through the shared FindBar and desktop bridge find APIs", () => {
|
||||
renderBrowserPane();
|
||||
markWebviewDomReady();
|
||||
openFind();
|
||||
|
||||
changeInput("needle");
|
||||
|
||||
expect(desktopBridge.onFoundInPage).toHaveBeenCalledWith("browser-a", expect.any(Function));
|
||||
expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", {
|
||||
forward: true,
|
||||
findNext: false,
|
||||
matchCase: false,
|
||||
});
|
||||
expect(container?.textContent).toContain("Searching...");
|
||||
dispatchFoundInPage({ requestId: 1, activeMatchOrdinal: 2, matches: 5 });
|
||||
expect(container?.textContent).toContain("2 / 5");
|
||||
|
||||
pressKey("Enter");
|
||||
expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", {
|
||||
forward: true,
|
||||
findNext: true,
|
||||
matchCase: false,
|
||||
});
|
||||
dispatchFoundInPage({ requestId: 2, activeMatchOrdinal: 3, matches: 5 });
|
||||
|
||||
pressKey("Enter", true);
|
||||
expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", {
|
||||
forward: false,
|
||||
findNext: true,
|
||||
matchCase: false,
|
||||
});
|
||||
|
||||
click("pane-find-close");
|
||||
|
||||
expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection");
|
||||
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("cleans browser find selection on empty query, navigation, blur, and unmount", () => {
|
||||
renderBrowserPane();
|
||||
markWebviewDomReady();
|
||||
openFind();
|
||||
changeInput("needle");
|
||||
dispatchFoundInPage({ requestId: 1, activeMatchOrdinal: 1, matches: 3 });
|
||||
|
||||
changeInput("");
|
||||
expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection");
|
||||
expect(container?.textContent).toContain("0 / 0");
|
||||
dispatchFoundInPage({ activeMatchOrdinal: 2, matches: 9 });
|
||||
expect(container?.textContent).toContain("0 / 0");
|
||||
|
||||
changeInput("needle");
|
||||
expect(container?.textContent).toContain("Searching...");
|
||||
act(() => {
|
||||
webview?.dispatchEvent(new Event("did-start-loading"));
|
||||
});
|
||||
expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection");
|
||||
expect(container?.textContent).toContain("0 / 0");
|
||||
|
||||
act(() => {
|
||||
webview?.dispatchEvent(new Event("dom-ready"));
|
||||
});
|
||||
expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", {
|
||||
forward: true,
|
||||
findNext: false,
|
||||
matchCase: false,
|
||||
});
|
||||
|
||||
renderBrowserPane({ isInteractive: false });
|
||||
expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection");
|
||||
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
expect(desktopBridge.stopFindInPage).toHaveBeenLastCalledWith("browser-a", "clearSelection");
|
||||
expect(desktopBridge.foundInPageListeners.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not call the browser find bridge before dom-ready", () => {
|
||||
renderBrowserPane();
|
||||
openFind();
|
||||
|
||||
changeInput("needle");
|
||||
expect(container?.textContent).toContain("Searching...");
|
||||
expect(desktopBridge.findInPage).not.toHaveBeenCalled();
|
||||
|
||||
click("pane-find-close");
|
||||
expect(desktopBridge.stopFindInPage).not.toHaveBeenCalled();
|
||||
|
||||
openFind();
|
||||
changeInput("needle");
|
||||
markWebviewDomReady();
|
||||
expect(desktopBridge.findInPage).toHaveBeenLastCalledWith("browser-a", "needle", {
|
||||
forward: true,
|
||||
findNext: false,
|
||||
matchCase: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -20,9 +20,12 @@ import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import {
|
||||
getDesktopHost,
|
||||
isElectronRuntime,
|
||||
type DesktopBrowserFoundInPageResult,
|
||||
type DesktopBrowserFindAction,
|
||||
type DesktopBrowserShortcutEvent,
|
||||
} from "@/desktop/host";
|
||||
import { isDev } from "@/constants/platform";
|
||||
import { FindBar, usePaneFind, type PaneFindMatchState } from "@/panels/pane-find";
|
||||
import { useBrowserStore, normalizeWorkspaceBrowserUrl } from "@/stores/browser-store";
|
||||
|
||||
type ElectronWebview = HTMLElement & {
|
||||
@@ -50,6 +53,15 @@ type BrowserElementSelection = Omit<BrowserElementAttachment, "formatted"> & {
|
||||
|
||||
const ERR_ABORTED = -3;
|
||||
const ALLOWED_BROWSER_PROTOCOLS = new Set(["http:", "https:"]);
|
||||
const EMPTY_FIND_MATCH_STATE: PaneFindMatchState = { status: "empty" };
|
||||
const NO_FIND_MATCH_STATE: PaneFindMatchState = { status: "no-match" };
|
||||
const PENDING_FIND_MATCH_STATE: PaneFindMatchState = { status: "pending" };
|
||||
|
||||
interface ActiveBrowserFind {
|
||||
generation: number;
|
||||
query: string;
|
||||
requestId: number | null;
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxLength: number): string {
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength).trim()}...` : value;
|
||||
@@ -234,6 +246,18 @@ function isDesktopBrowserShortcutEvent(payload: unknown): payload is DesktopBrow
|
||||
return event.action === "focus-url";
|
||||
}
|
||||
|
||||
function stopBrowserFindInPage(input: {
|
||||
browserId: string;
|
||||
action: DesktopBrowserFindAction;
|
||||
}): void {
|
||||
const bridge = getDesktopHost()?.browser;
|
||||
if (!bridge?.stopFindInPage) {
|
||||
console.warn("Electron browser find bridge is unavailable; cannot stop find-in-page.");
|
||||
return;
|
||||
}
|
||||
void bridge.stopFindInPage(input.browserId, input.action);
|
||||
}
|
||||
|
||||
function startSelectorResultPolling(input: {
|
||||
webview: ElectronWebview;
|
||||
onSelection: (selection: BrowserElementSelection) => void;
|
||||
@@ -295,7 +319,13 @@ export function BrowserPane({
|
||||
browserRef.current = browser;
|
||||
const pendingNavigationUrlRef = useRef<string | null>(null);
|
||||
const domReadyRef = useRef(false);
|
||||
const browserFindQueryRef = useRef("");
|
||||
const browserFindGenerationRef = useRef(0);
|
||||
const activeBrowserFindRef = useRef<ActiveBrowserFind | null>(null);
|
||||
const browserFindOpenRef = useRef(false);
|
||||
const [selectorActive, setSelectorActive] = useState(false);
|
||||
const [browserFindMatchState, setBrowserFindMatchState] =
|
||||
useState<PaneFindMatchState>(EMPTY_FIND_MATCH_STATE);
|
||||
const [draftUrl, setDraftUrl] = useState(browser?.url ?? "https://example.com");
|
||||
const workspaceAttachmentScopeKey = useMemo(
|
||||
() => buildBrowserAttachmentScopeKey({ cwd, serverId, workspaceId }),
|
||||
@@ -372,6 +402,115 @@ export function BrowserPane({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearBrowserFindSelection = useCallback((input?: { resetQuery?: boolean }) => {
|
||||
if (input?.resetQuery) {
|
||||
browserFindQueryRef.current = "";
|
||||
}
|
||||
browserFindGenerationRef.current += 1;
|
||||
activeBrowserFindRef.current = null;
|
||||
setBrowserFindMatchState(EMPTY_FIND_MATCH_STATE);
|
||||
if (domReadyRef.current) {
|
||||
stopBrowserFindInPage({
|
||||
browserId: browserIdRef.current,
|
||||
action: "clearSelection",
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runBrowserFind = useCallback(
|
||||
(query: string, direction: "next" | "previous", input?: { reset?: boolean }) => {
|
||||
browserFindQueryRef.current = query;
|
||||
if (!query) {
|
||||
clearBrowserFindSelection({ resetQuery: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!domReadyRef.current) {
|
||||
setBrowserFindMatchState(PENDING_FIND_MATCH_STATE);
|
||||
return;
|
||||
}
|
||||
const bridgeFindInPage = getDesktopHost()?.browser?.findInPage;
|
||||
if (!bridgeFindInPage) {
|
||||
console.warn("Electron browser find bridge is unavailable; cannot start find-in-page.");
|
||||
setBrowserFindMatchState(NO_FIND_MATCH_STATE);
|
||||
return;
|
||||
}
|
||||
|
||||
const generation = (browserFindGenerationRef.current += 1);
|
||||
activeBrowserFindRef.current = {
|
||||
generation,
|
||||
query,
|
||||
requestId: null,
|
||||
};
|
||||
setBrowserFindMatchState(PENDING_FIND_MATCH_STATE);
|
||||
|
||||
const options = {
|
||||
forward: direction === "next",
|
||||
findNext: !input?.reset,
|
||||
matchCase: false,
|
||||
};
|
||||
const requestIdResult = bridgeFindInPage(browserIdRef.current, query, options);
|
||||
if (typeof requestIdResult === "number") {
|
||||
activeBrowserFindRef.current = {
|
||||
generation,
|
||||
query,
|
||||
requestId: requestIdResult,
|
||||
};
|
||||
return;
|
||||
}
|
||||
void Promise.resolve(requestIdResult ?? null).then((requestId) => {
|
||||
const activeFind = activeBrowserFindRef.current;
|
||||
if (
|
||||
!activeFind ||
|
||||
activeFind.generation !== generation ||
|
||||
activeFind.query !== browserFindQueryRef.current
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
activeBrowserFindRef.current = {
|
||||
...activeFind,
|
||||
requestId: typeof requestId === "number" ? requestId : null,
|
||||
};
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
[clearBrowserFindSelection],
|
||||
);
|
||||
|
||||
const handleFoundInPageResult = useCallback((result: DesktopBrowserFoundInPageResult) => {
|
||||
const activeFind = activeBrowserFindRef.current;
|
||||
if (
|
||||
!activeFind ||
|
||||
activeFind.generation !== browserFindGenerationRef.current ||
|
||||
activeFind.query !== browserFindQueryRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const requestId = result.requestId;
|
||||
if (typeof requestId !== "number") {
|
||||
return;
|
||||
}
|
||||
if (typeof activeFind.requestId !== "number" || requestId !== activeFind.requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const total = typeof result.matches === "number" ? Math.max(0, result.matches) : 0;
|
||||
if (total === 0) {
|
||||
setBrowserFindMatchState(NO_FIND_MATCH_STATE);
|
||||
return;
|
||||
}
|
||||
|
||||
const current =
|
||||
typeof result.activeMatchOrdinal === "number" && result.activeMatchOrdinal > 0
|
||||
? result.activeMatchOrdinal
|
||||
: 1;
|
||||
setBrowserFindMatchState({
|
||||
status: "matched",
|
||||
current: Math.min(current, total),
|
||||
total,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectronRuntime()) {
|
||||
return;
|
||||
@@ -403,6 +542,7 @@ export function BrowserPane({
|
||||
webview.style.background = "transparent";
|
||||
|
||||
const handleStartLoading = () => {
|
||||
clearBrowserFindSelection();
|
||||
updateBrowser(browserId, { isLoading: true, lastError: null });
|
||||
syncNavigationState({ syncUrl: false });
|
||||
};
|
||||
@@ -429,6 +569,7 @@ export function BrowserPane({
|
||||
syncNavigationState();
|
||||
};
|
||||
const handleWillNavigate = (event: Event) => {
|
||||
clearBrowserFindSelection();
|
||||
const nextUrl =
|
||||
typeof (event as Event & { url?: unknown }).url === "string"
|
||||
? ((event as Event & { url?: string }).url ?? "")
|
||||
@@ -471,6 +612,9 @@ export function BrowserPane({
|
||||
const handleDomReady = () => {
|
||||
domReadyRef.current = true;
|
||||
syncNavigationState();
|
||||
if (browserFindOpenRef.current && browserFindQueryRef.current) {
|
||||
runBrowserFind(browserFindQueryRef.current, "next", { reset: true });
|
||||
}
|
||||
};
|
||||
const handleWebviewFocus = () => {
|
||||
onFocusPane?.();
|
||||
@@ -488,6 +632,29 @@ export function BrowserPane({
|
||||
webview.addEventListener("focus", handleWebviewFocus);
|
||||
webview.addEventListener("mousedown", handleWebviewFocus);
|
||||
|
||||
const foundInPageBridge = getDesktopHost()?.browser?.onFoundInPage;
|
||||
let unsubscribeFoundInPage: (() => void) | null = null;
|
||||
let didCleanupFoundInPage = false;
|
||||
if (foundInPageBridge) {
|
||||
const unsubscribeResult = foundInPageBridge(browserId, (result) => {
|
||||
handleFoundInPageResult(result);
|
||||
});
|
||||
if (typeof unsubscribeResult === "function") {
|
||||
unsubscribeFoundInPage = unsubscribeResult;
|
||||
} else {
|
||||
void Promise.resolve(unsubscribeResult).then((unsubscribe) => {
|
||||
if (didCleanupFoundInPage) {
|
||||
unsubscribe();
|
||||
return undefined;
|
||||
}
|
||||
unsubscribeFoundInPage = unsubscribe;
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn("Electron browser find bridge is unavailable; found-in-page events disabled.");
|
||||
}
|
||||
|
||||
host.appendChild(webview);
|
||||
if (initialUnsafeNavigationMessage) {
|
||||
updateBrowserRef.current(browserIdRef.current, {
|
||||
@@ -506,8 +673,16 @@ export function BrowserPane({
|
||||
webview.removeEventListener("page-favicon-updated", handleFaviconUpdated);
|
||||
webview.removeEventListener("did-fail-load", handleLoadFailed);
|
||||
webview.removeEventListener("dom-ready", handleDomReady);
|
||||
didCleanupFoundInPage = true;
|
||||
unsubscribeFoundInPage?.();
|
||||
webview.removeEventListener("focus", handleWebviewFocus);
|
||||
webview.removeEventListener("mousedown", handleWebviewFocus);
|
||||
if (domReadyRef.current) {
|
||||
stopBrowserFindInPage({
|
||||
browserId: browserIdRef.current,
|
||||
action: "clearSelection",
|
||||
});
|
||||
}
|
||||
if (host.contains(webview)) {
|
||||
host.removeChild(webview);
|
||||
}
|
||||
@@ -519,6 +694,35 @@ export function BrowserPane({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [browserId, onFocusPane]);
|
||||
|
||||
const paneFind = usePaneFind({
|
||||
matchState: browserFindMatchState,
|
||||
onQuery: (query) => runBrowserFind(query, "next", { reset: true }),
|
||||
onNext: () => runBrowserFind(browserFindQueryRef.current, "next"),
|
||||
onPrev: () => runBrowserFind(browserFindQueryRef.current, "previous"),
|
||||
onClose: () => clearBrowserFindSelection({ resetQuery: true }),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
browserFindOpenRef.current = paneFind.isOpen;
|
||||
if (!paneFind.isOpen) {
|
||||
clearBrowserFindSelection({ resetQuery: true });
|
||||
}
|
||||
}, [clearBrowserFindSelection, paneFind.isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInteractive) {
|
||||
if (paneFind.isOpen && browserFindQueryRef.current) {
|
||||
runBrowserFind(browserFindQueryRef.current, "next", { reset: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
clearBrowserFindSelection();
|
||||
}, [clearBrowserFindSelection, isInteractive, paneFind.isOpen, runBrowserFind]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearBrowserFindSelection({ resetQuery: true });
|
||||
}, [clearBrowserFindSelection]);
|
||||
|
||||
const navigate = useCallback((nextUrl: string) => {
|
||||
const normalizedUrl = normalizeWorkspaceBrowserUrl(nextUrl);
|
||||
const webview = webviewRef.current;
|
||||
@@ -939,6 +1143,7 @@ export function BrowserPane({
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null}
|
||||
<View style={styles.chromeRow}>
|
||||
<View style={styles.chromeLeft}>
|
||||
<Pressable
|
||||
|
||||
@@ -685,6 +685,10 @@ export function CombinedModelSelector({
|
||||
handleOpenChange(!isOpen);
|
||||
}, [handleOpenChange, isOpen]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
handleOpenChange(false);
|
||||
}, [handleOpenChange]);
|
||||
|
||||
const triggerStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.trigger,
|
||||
@@ -710,10 +714,11 @@ export function CombinedModelSelector({
|
||||
setSearchQuery(value);
|
||||
}, []);
|
||||
|
||||
const openProviderSettings = useCallback(() => {
|
||||
const openHeaderProviderSettings = useCallback(() => {
|
||||
if (!serverId || view.kind !== "provider") return;
|
||||
useProviderSettingsStore.getState().open({ serverId, provider: view.providerId });
|
||||
}, [serverId, view]);
|
||||
handleClose();
|
||||
}, [serverId, view, handleClose]);
|
||||
|
||||
const sheetHeader = useMemo<SheetHeader>(() => {
|
||||
if (view.kind === "all") {
|
||||
@@ -722,7 +727,7 @@ export function CombinedModelSelector({
|
||||
const ProviderIconForView = getProviderIcon(view.providerId);
|
||||
const headerActions = (
|
||||
<Pressable
|
||||
onPress={openProviderSettings}
|
||||
onPress={openHeaderProviderSettings}
|
||||
disabled={!serverId}
|
||||
hitSlop={8}
|
||||
style={iconButtonStyle}
|
||||
@@ -755,7 +760,7 @@ export function CombinedModelSelector({
|
||||
view,
|
||||
singleProviderView,
|
||||
serverId,
|
||||
openProviderSettings,
|
||||
openHeaderProviderSettings,
|
||||
theme.colors.border,
|
||||
theme.colors.foregroundMuted,
|
||||
handleBackToAll,
|
||||
|
||||
@@ -9,39 +9,24 @@ import {
|
||||
} from "react-native";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import { Home, Plus, Settings } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useCommandCenter } from "@/hooks/use-command-center";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { AgentStatusDot } from "@/components/agent-status-dot";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetTextInput,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
|
||||
return `${agent.serverId}:${agent.id}`;
|
||||
}
|
||||
|
||||
const ThemedBottomSheetTextInput = withUnistyles(BottomSheetTextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
interface CommandCenterRowProps {
|
||||
active: boolean;
|
||||
children: ReactNode;
|
||||
onPress: () => void;
|
||||
registerRow: (el: View | null) => void;
|
||||
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
|
||||
}
|
||||
|
||||
const CommandCenterRow = memo(function CommandCenterRow({
|
||||
@@ -49,7 +34,6 @@ const CommandCenterRow = memo(function CommandCenterRow({
|
||||
children,
|
||||
onPress,
|
||||
registerRow,
|
||||
onLayout,
|
||||
}: CommandCenterRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
@@ -64,7 +48,7 @@ const CommandCenterRow = memo(function CommandCenterRow({
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable ref={registerRow} style={pressableStyle} onPress={onPress} onLayout={onLayout}>
|
||||
<Pressable ref={registerRow} style={pressableStyle} onPress={onPress}>
|
||||
{children}
|
||||
</Pressable>
|
||||
);
|
||||
@@ -75,7 +59,6 @@ interface CommandCenterRowContainerProps {
|
||||
active: boolean;
|
||||
rowRefs: React.MutableRefObject<Map<number, View>>;
|
||||
onPress: () => void;
|
||||
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -84,7 +67,6 @@ function CommandCenterRowContainer({
|
||||
active,
|
||||
rowRefs,
|
||||
onPress,
|
||||
onLayout,
|
||||
children,
|
||||
}: CommandCenterRowContainerProps) {
|
||||
const registerRow = useCallback(
|
||||
@@ -95,12 +77,7 @@ function CommandCenterRowContainer({
|
||||
[rowRefs, rowIndex],
|
||||
);
|
||||
return (
|
||||
<CommandCenterRow
|
||||
active={active}
|
||||
registerRow={registerRow}
|
||||
onPress={onPress}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
<CommandCenterRow active={active} registerRow={registerRow} onPress={onPress}>
|
||||
{children}
|
||||
</CommandCenterRow>
|
||||
);
|
||||
@@ -111,7 +88,6 @@ interface CommandCenterActionRowProps {
|
||||
rowIndex: number;
|
||||
active: boolean;
|
||||
rowRefs: React.MutableRefObject<Map<number, View>>;
|
||||
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
|
||||
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
|
||||
}
|
||||
|
||||
@@ -120,7 +96,6 @@ function CommandCenterActionRow({
|
||||
rowIndex,
|
||||
active,
|
||||
rowRefs,
|
||||
onLayout,
|
||||
onSelect,
|
||||
}: CommandCenterActionRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -144,7 +119,6 @@ function CommandCenterActionRow({
|
||||
active={active}
|
||||
rowRefs={rowRefs}
|
||||
onPress={handlePress}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowMain}>
|
||||
@@ -168,7 +142,6 @@ interface CommandCenterAgentRowProps {
|
||||
rowIndex: number;
|
||||
active: boolean;
|
||||
rowRefs: React.MutableRefObject<Map<number, View>>;
|
||||
onLayout?: (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
|
||||
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -177,7 +150,6 @@ function CommandCenterAgentRow({
|
||||
rowIndex,
|
||||
active,
|
||||
rowRefs,
|
||||
onLayout,
|
||||
onSelect,
|
||||
item,
|
||||
children,
|
||||
@@ -189,7 +161,6 @@ function CommandCenterAgentRow({
|
||||
active={active}
|
||||
rowRefs={rowRefs}
|
||||
onPress={handlePress}
|
||||
onLayout={onLayout}
|
||||
>
|
||||
{children}
|
||||
</CommandCenterRowContainer>
|
||||
@@ -238,9 +209,6 @@ interface AgentItemsSectionProps {
|
||||
actionItemsLength: number;
|
||||
activeIndex: number;
|
||||
rowRefs: React.MutableRefObject<Map<number, View>>;
|
||||
onRowLayout: (
|
||||
rowIndex: number,
|
||||
) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => void;
|
||||
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
|
||||
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
|
||||
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
|
||||
@@ -251,7 +219,6 @@ function AgentItemsSection({
|
||||
actionItemsLength,
|
||||
activeIndex,
|
||||
rowRefs,
|
||||
onRowLayout,
|
||||
onSelect,
|
||||
sectionDividerStyle,
|
||||
sectionLabelStyle,
|
||||
@@ -270,7 +237,6 @@ function AgentItemsSection({
|
||||
rowIndex={rowIndex}
|
||||
active={rowIndex === activeIndex}
|
||||
rowRefs={rowRefs}
|
||||
onLayout={onRowLayout(rowIndex)}
|
||||
onSelect={onSelect}
|
||||
>
|
||||
<CommandCenterAgentRowContent agent={agent} />
|
||||
@@ -283,119 +249,50 @@ function AgentItemsSection({
|
||||
|
||||
export function CommandCenter() {
|
||||
const { theme } = useUnistyles();
|
||||
const {
|
||||
open,
|
||||
inputRef,
|
||||
query,
|
||||
setQuery,
|
||||
activeIndex,
|
||||
items,
|
||||
handleClose,
|
||||
handleSelectItem,
|
||||
handleKeyEvent,
|
||||
} = useCommandCenter();
|
||||
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const showBottomSheet = isCompact && isNative;
|
||||
const { open, inputRef, query, setQuery, activeIndex, items, handleClose, handleSelectItem } =
|
||||
useCommandCenter();
|
||||
|
||||
const rowRefs = useRef<Map<number, View>>(new Map());
|
||||
const rowLayouts = useRef<Map<number, { y: number; height: number }>>(new Map());
|
||||
const resultsRef = useRef<ScrollView>(null);
|
||||
const nativeScrollY = useRef(0);
|
||||
const nativeViewHeight = useRef(0);
|
||||
// BottomSheetTextInput wraps a different TextInput type (from react-native-gesture-handler).
|
||||
// Use a loose ref to avoid the type mismatch — same pattern as AdaptiveTextInput.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const bottomSheetInputRef = useRef<any>(null);
|
||||
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible: open,
|
||||
isEnabled: showBottomSheet,
|
||||
onClose: handleClose,
|
||||
});
|
||||
|
||||
// Focus the bottom sheet input when the sheet opens on mobile
|
||||
useEffect(() => {
|
||||
if (showBottomSheet && open) {
|
||||
const id = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
|
||||
return () => clearTimeout(id);
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
}, [showBottomSheet, open]);
|
||||
const row = rowRefs.current.get(activeIndex);
|
||||
if (!row || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
const scrollNode =
|
||||
(
|
||||
resultsRef.current as
|
||||
| (ScrollView & {
|
||||
getScrollableNode?: () => HTMLElement | null;
|
||||
})
|
||||
| null
|
||||
)?.getScrollableNode?.() ?? null;
|
||||
const rowEl = row as unknown as HTMLElement;
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
// Scroll active row into view
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (isWeb) {
|
||||
const row = rowRefs.current.get(activeIndex);
|
||||
if (!row || typeof document === "undefined") return;
|
||||
const scrollNode =
|
||||
(
|
||||
resultsRef.current as
|
||||
| (ScrollView & {
|
||||
getScrollableNode?: () => HTMLElement | null;
|
||||
})
|
||||
| null
|
||||
)?.getScrollableNode?.() ?? null;
|
||||
const rowEl = row as unknown as HTMLElement;
|
||||
|
||||
if (!scrollNode) {
|
||||
rowEl.scrollIntoView?.({ block: "nearest" });
|
||||
return;
|
||||
}
|
||||
|
||||
const rowTop = rowEl.offsetTop;
|
||||
const rowBottom = rowTop + rowEl.offsetHeight;
|
||||
const visibleTop = scrollNode.scrollTop;
|
||||
const visibleBottom = visibleTop + scrollNode.clientHeight;
|
||||
|
||||
if (rowTop < visibleTop) {
|
||||
scrollNode.scrollTop = rowTop;
|
||||
return;
|
||||
}
|
||||
|
||||
if (rowBottom > visibleBottom) {
|
||||
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight;
|
||||
}
|
||||
if (!scrollNode) {
|
||||
rowEl.scrollIntoView?.({ block: "nearest" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Native: use onLayout-measured positions
|
||||
const layout = rowLayouts.current.get(activeIndex);
|
||||
if (!layout || !resultsRef.current) return;
|
||||
|
||||
const rowTop = layout.y;
|
||||
const rowBottom = rowTop + layout.height;
|
||||
const visibleTop = nativeScrollY.current;
|
||||
const visibleBottom = visibleTop + nativeViewHeight.current;
|
||||
const rowTop = rowEl.offsetTop;
|
||||
const rowBottom = rowTop + rowEl.offsetHeight;
|
||||
const visibleTop = scrollNode.scrollTop;
|
||||
const visibleBottom = visibleTop + scrollNode.clientHeight;
|
||||
|
||||
if (rowTop < visibleTop) {
|
||||
resultsRef.current.scrollTo?.({ y: rowTop, animated: true });
|
||||
} else if (rowBottom > visibleBottom) {
|
||||
resultsRef.current.scrollTo?.({
|
||||
y: rowBottom - nativeViewHeight.current,
|
||||
animated: true,
|
||||
});
|
||||
scrollNode.scrollTop = rowTop;
|
||||
return;
|
||||
}
|
||||
|
||||
if (rowBottom > visibleBottom) {
|
||||
scrollNode.scrollTop = rowBottom - scrollNode.clientHeight;
|
||||
}
|
||||
}, [activeIndex, open]);
|
||||
|
||||
const handleRowLayout = useCallback(
|
||||
(rowIndex: number) => (event: { nativeEvent: { layout: { y: number; height: number } } }) => {
|
||||
rowLayouts.current.set(rowIndex, {
|
||||
y: event.nativeEvent.layout.y,
|
||||
height: event.nativeEvent.layout.height,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const actionItems = useMemo(() => items.filter((item) => item.kind === "action"), [items]);
|
||||
const agentItems = useMemo(() => items.filter((item) => item.kind === "agent"), [items]);
|
||||
|
||||
@@ -427,101 +324,8 @@ export function CommandCenter() {
|
||||
[theme.colors.border],
|
||||
);
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => {
|
||||
handleKeyEvent(key);
|
||||
},
|
||||
[handleKeyEvent],
|
||||
);
|
||||
if (isNative || !open) return null;
|
||||
|
||||
const handleSubmitEditing = useCallback(() => {
|
||||
handleKeyEvent("Enter");
|
||||
}, [handleKeyEvent]);
|
||||
|
||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||
|
||||
const resultList =
|
||||
items.length === 0 ? (
|
||||
<Text style={emptyTextStyle}>No matches</Text>
|
||||
) : (
|
||||
<>
|
||||
{actionItems.length > 0 ? (
|
||||
<>
|
||||
<Text style={sectionLabelStyle}>Actions</Text>
|
||||
{actionItems.map((item, index) => (
|
||||
<CommandCenterActionRow
|
||||
key={`action:${item.action.id}`}
|
||||
item={item}
|
||||
rowIndex={index}
|
||||
active={index === activeIndex}
|
||||
rowRefs={rowRefs}
|
||||
onLayout={handleRowLayout(index)}
|
||||
onSelect={handleSelectItem}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{agentItems.length > 0 ? (
|
||||
<AgentItemsSection
|
||||
agentItems={agentItems}
|
||||
actionItemsLength={actionItems.length}
|
||||
activeIndex={activeIndex}
|
||||
rowRefs={rowRefs}
|
||||
onRowLayout={handleRowLayout}
|
||||
onSelect={handleSelectItem}
|
||||
sectionDividerStyle={sectionDividerStyle}
|
||||
sectionLabelStyle={sectionLabelStyle}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
// Mobile: bottom sheet
|
||||
if (showBottomSheet) {
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
snapPoints={snapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
accessible={false}
|
||||
>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<ThemedBottomSheetTextInput
|
||||
testID="command-center-input"
|
||||
ref={bottomSheetInputRef as unknown as React.Ref<never>}
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
onKeyPress={handleKeyPress}
|
||||
onSubmitEditing={handleSubmitEditing}
|
||||
placeholder="Type a command or search agents..."
|
||||
style={inputStyle}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.resultsContent}
|
||||
keyboardShouldPersistTaps="always"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{resultList}
|
||||
</BottomSheetScrollView>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Desktop web: centered overlay panel
|
||||
return (
|
||||
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
|
||||
<View style={styles.overlay}>
|
||||
@@ -550,7 +354,39 @@ export function CommandCenter() {
|
||||
keyboardShouldPersistTaps="always"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{resultList}
|
||||
{items.length === 0 ? (
|
||||
<Text style={emptyTextStyle}>No matches</Text>
|
||||
) : (
|
||||
<>
|
||||
{actionItems.length > 0 ? (
|
||||
<>
|
||||
<Text style={sectionLabelStyle}>Actions</Text>
|
||||
{actionItems.map((item, index) => (
|
||||
<CommandCenterActionRow
|
||||
key={`action:${item.action.id}`}
|
||||
item={item}
|
||||
rowIndex={index}
|
||||
active={index === activeIndex}
|
||||
rowRefs={rowRefs}
|
||||
onSelect={handleSelectItem}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{agentItems.length > 0 ? (
|
||||
<AgentItemsSection
|
||||
agentItems={agentItems}
|
||||
actionItemsLength={actionItems.length}
|
||||
activeIndex={activeIndex}
|
||||
rowRefs={rowRefs}
|
||||
onSelect={handleSelectItem}
|
||||
sectionDividerStyle={sectionDividerStyle}
|
||||
sectionLabelStyle={sectionLabelStyle}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
@@ -559,12 +395,6 @@ export function CommandCenter() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
bottomSheetHeader: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-start",
|
||||
|
||||
@@ -7,19 +7,13 @@ interface ContextWindowMeterProps {
|
||||
maxTokens: number;
|
||||
usedTokens: number;
|
||||
totalCostUsd?: number | null;
|
||||
showPercentage?: boolean;
|
||||
}
|
||||
|
||||
const SVG_SIZE = 16;
|
||||
const COMPACT_SVG_SIZE = 14;
|
||||
const CENTER = SVG_SIZE / 2;
|
||||
const COMPACT_CENTER = COMPACT_SVG_SIZE / 2;
|
||||
const RADIUS = 7;
|
||||
const COMPACT_RADIUS = 6;
|
||||
const STROKE_WIDTH = 2.25;
|
||||
const COMPACT_STROKE_WIDTH = 2;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
const COMPACT_CIRCUMFERENCE = 2 * Math.PI * COMPACT_RADIUS;
|
||||
|
||||
function isValidMaxTokens(value: number): boolean {
|
||||
return Number.isFinite(value) && value > 0;
|
||||
@@ -78,7 +72,6 @@ export function ContextWindowMeter({
|
||||
maxTokens,
|
||||
usedTokens,
|
||||
totalCostUsd,
|
||||
showPercentage = false,
|
||||
}: ContextWindowMeterProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const percentage = getUsagePercentage(maxTokens, usedTokens);
|
||||
@@ -89,56 +82,47 @@ export function ContextWindowMeter({
|
||||
|
||||
const clampedPercentage = clampPercentage(percentage);
|
||||
const roundedPercentage = Math.round(percentage);
|
||||
const svgSize = showPercentage ? COMPACT_SVG_SIZE : SVG_SIZE;
|
||||
const center = showPercentage ? COMPACT_CENTER : CENTER;
|
||||
const radius = showPercentage ? COMPACT_RADIUS : RADIUS;
|
||||
const strokeWidth = showPercentage ? COMPACT_STROKE_WIDTH : STROKE_WIDTH;
|
||||
const circumference = showPercentage ? COMPACT_CIRCUMFERENCE : CIRCUMFERENCE;
|
||||
const dashOffset = circumference - (clampedPercentage / 100) * circumference;
|
||||
const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE;
|
||||
const colors = getMeterColors(clampedPercentage, theme);
|
||||
const formattedSessionCost =
|
||||
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
|
||||
const containerStyle = showPercentage ? styles.containerWithLabel : styles.container;
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
style={containerStyle}
|
||||
style={styles.container}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={`Context window ${roundedPercentage}% used`}
|
||||
>
|
||||
<Svg
|
||||
width={svgSize}
|
||||
height={svgSize}
|
||||
viewBox={`0 0 ${svgSize} ${svgSize}`}
|
||||
width={SVG_SIZE}
|
||||
height={SVG_SIZE}
|
||||
viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`}
|
||||
style={styles.svg}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
stroke={colors.track}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
/>
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
r={RADIUS}
|
||||
fill="none"
|
||||
stroke={colors.progress}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDasharray={CIRCUMFERENCE}
|
||||
strokeDashoffset={dashOffset}
|
||||
/>
|
||||
</Svg>
|
||||
{showPercentage ? (
|
||||
<Text style={styles.percentageLabel}>{`${roundedPercentage}%`}</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
@@ -165,22 +149,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
containerWithLabel: {
|
||||
height: 28,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
svg: {
|
||||
transform: [{ rotate: "-90deg" }],
|
||||
},
|
||||
percentageLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
tooltipContent: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
|
||||
@@ -28,7 +28,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
height: 20,
|
||||
gap: 4,
|
||||
flexShrink: 0,
|
||||
},
|
||||
|
||||
@@ -2,13 +2,13 @@ import React from "react";
|
||||
import { View, Text, ScrollView as RNScrollView } from "react-native";
|
||||
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import type { DiffLine } from "@/utils/tool-call-parsers";
|
||||
import { diffLinePrefix } from "@/utils/diff-highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { getCodeInsets } from "./code-insets";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const ScrollView = isWeb ? RNScrollView : GHScrollView;
|
||||
@@ -165,7 +165,7 @@ export function DiffViewer({
|
||||
}
|
||||
|
||||
const lines = (
|
||||
<View style={linesContainerStyle} dataSet={CODE_SURFACE_DATASET}>
|
||||
<View style={linesContainerStyle}>
|
||||
{keyedDiffLines.map(({ key, line }) => (
|
||||
<DiffLineRow key={key} line={line} />
|
||||
))}
|
||||
@@ -226,7 +226,7 @@ const styles = StyleSheet.create((theme) => {
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
lineText: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
...(isWeb
|
||||
|
||||
@@ -29,7 +29,6 @@ export function DraggableList<T>({
|
||||
useDragHandle: _useDragHandle = false,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
extraData,
|
||||
simultaneousGestureRef,
|
||||
waitFor,
|
||||
onDragBegin: onDragBeginProp,
|
||||
@@ -117,7 +116,6 @@ export function DraggableList<T>({
|
||||
ListEmptyComponent={ListEmptyComponent}
|
||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||
scrollEnabled={scrollEnabled}
|
||||
extraData={extraData}
|
||||
simultaneousHandlers={simultaneousHandlers}
|
||||
// Higher activation distance reduces accidental drag capture while nested
|
||||
// lists are inside a scroll container.
|
||||
|
||||
@@ -46,8 +46,6 @@ export interface DraggableListProps<T> {
|
||||
onRefresh?: () => void;
|
||||
/** Fill remaining space when content is smaller than container */
|
||||
contentContainerFlexGrow?: boolean;
|
||||
/** External row state that should invalidate virtualized native cells. */
|
||||
extraData?: unknown;
|
||||
/** Gesture ref for simultaneous handling with parent gestures (e.g., sidebar close) */
|
||||
simultaneousGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
/** Gesture ref(s) that the list should wait for before handling scroll */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useMemo, useRef, type ReactElement } from "react";
|
||||
import { useCallback, useMemo, useRef, type ReactElement } from "react";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import {
|
||||
DndContext,
|
||||
@@ -43,7 +43,7 @@ interface SortableItemProps<T> {
|
||||
useDragHandle: boolean;
|
||||
}
|
||||
|
||||
function SortableItemInner<T>({
|
||||
function SortableItem<T>({
|
||||
id,
|
||||
item,
|
||||
index,
|
||||
@@ -118,8 +118,6 @@ function SortableItemInner<T>({
|
||||
);
|
||||
}
|
||||
|
||||
const SortableItem = memo(SortableItemInner) as typeof SortableItemInner;
|
||||
|
||||
export function DraggableList<T>({
|
||||
data,
|
||||
keyExtractor,
|
||||
@@ -135,7 +133,6 @@ export function DraggableList<T>({
|
||||
showsVerticalScrollIndicator = true,
|
||||
enableDesktopWebScrollbar = false,
|
||||
scrollEnabled = true,
|
||||
extraData: _extraData,
|
||||
useDragHandle = false,
|
||||
// simultaneousGestureRef is native-only, ignored on web
|
||||
onDragBegin,
|
||||
|
||||
@@ -23,11 +23,6 @@ import {
|
||||
type ExplorerTab,
|
||||
} from "@/stores/panel-store";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import {
|
||||
MOBILE_VISUAL_PANEL_AGENT,
|
||||
MOBILE_VISUAL_PANEL_FILE_EXPLORER,
|
||||
useSidebarAnimation,
|
||||
} from "@/contexts/sidebar-animation-context";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { GitDiffPane } from "@/git/diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
@@ -68,8 +63,6 @@ export function ExplorerSidebar({
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const closeTouchStartX = useSharedValue(0);
|
||||
const closeTouchStartY = useSharedValue(0);
|
||||
const { mobileVisualPanel, gestureAnimatingRef: mobilePanelGestureAnimatingRef } =
|
||||
useSidebarAnimation();
|
||||
|
||||
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
|
||||
mode: "padding",
|
||||
@@ -121,11 +114,10 @@ export function ExplorerSidebar({
|
||||
|
||||
const handleCloseFromGesture = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
mobilePanelGestureAnimatingRef.current = true;
|
||||
showMobileAgent();
|
||||
}, [gestureAnimatingRef, mobilePanelGestureAnimatingRef, showMobileAgent]);
|
||||
}, [gestureAnimatingRef, showMobileAgent]);
|
||||
|
||||
const enableSidebarCloseGesture = isMobile;
|
||||
const enableSidebarCloseGesture = isMobile && isOpen;
|
||||
|
||||
const handleTabPress = useCallback(
|
||||
(tab: ExplorerTab) => {
|
||||
@@ -166,11 +158,6 @@ export function ExplorerSidebar({
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_FILE_EXPLORER) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fail quickly on clear leftward or vertical intent so child views keep control.
|
||||
if (deltaX <= -10) {
|
||||
stateManager.fail();
|
||||
@@ -206,11 +193,9 @@ export function ExplorerSidebar({
|
||||
windowWidth,
|
||||
});
|
||||
if (shouldClose) {
|
||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
|
||||
animateToClose();
|
||||
runOnJS(handleCloseFromGesture)();
|
||||
} else {
|
||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_FILE_EXPLORER;
|
||||
animateToOpen();
|
||||
}
|
||||
})
|
||||
@@ -222,7 +207,6 @@ export function ExplorerSidebar({
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
mobileVisualPanel,
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
handleCloseFromGesture,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import {
|
||||
@@ -1156,7 +1157,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
codeText: {
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
flexShrink: 0,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createFilePaneFindTokenSegments,
|
||||
createFilePaneLineFindHighlightMap,
|
||||
createFilePaneTextRenderData,
|
||||
findFilePaneTextMatches,
|
||||
} from "@/components/file-pane-text-render-data";
|
||||
|
||||
type FilePaneTextLineRenderData = ReturnType<typeof createFilePaneTextRenderData>["lines"][number];
|
||||
|
||||
function tokenText(line: FilePaneTextLineRenderData): string {
|
||||
return line.tokens.map(({ text }) => text).join("");
|
||||
}
|
||||
|
||||
describe("createFilePaneTextRenderData", () => {
|
||||
it("keeps code render lines in source order and reconstructs searchable text from tokens", () => {
|
||||
const renderData = createFilePaneTextRenderData(
|
||||
"const answer = 42;\nconsole.log(answer);",
|
||||
"src/answer.ts",
|
||||
);
|
||||
|
||||
expect(renderData.lines.map((line) => line.lineNumber)).toEqual([1, 2]);
|
||||
expect(renderData.lines.map((line) => line.text)).toEqual([
|
||||
"const answer = 42;",
|
||||
"console.log(answer);",
|
||||
]);
|
||||
expect(renderData.searchableText).toBe("const answer = 42;\nconsole.log(answer);");
|
||||
const tokenTexts = renderData.lines.map(tokenText);
|
||||
expect(tokenTexts).toEqual(renderData.lines.map((line) => line.text));
|
||||
});
|
||||
|
||||
it("preserves blank text lines for navigation and scrolling targets", () => {
|
||||
const renderData = createFilePaneTextRenderData("alpha\n\nbeta", "notes.txt");
|
||||
|
||||
expect(
|
||||
renderData.lines.map((line) => ({ lineNumber: line.lineNumber, text: line.text })),
|
||||
).toEqual([
|
||||
{ lineNumber: 1, text: "alpha" },
|
||||
{ lineNumber: 2, text: "" },
|
||||
{ lineNumber: 3, text: "beta" },
|
||||
]);
|
||||
expect(renderData.searchableText).toBe("alpha\n\nbeta");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findFilePaneTextMatches", () => {
|
||||
it("indexes loaded text case-insensitively in source order", () => {
|
||||
const renderData = createFilePaneTextRenderData(
|
||||
"Alpha beta\nBETA gamma\nalphabet",
|
||||
"notes.txt",
|
||||
);
|
||||
|
||||
const matches = findFilePaneTextMatches(renderData, "beta");
|
||||
|
||||
expect(
|
||||
matches.map((match) => ({
|
||||
index: match.index,
|
||||
lineSpans: match.lineSpans,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
index: 0,
|
||||
lineSpans: [{ lineNumber: 1, startColumn: 6, endColumn: 10 }],
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
lineSpans: [{ lineNumber: 2, startColumn: 0, endColumn: 4 }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps pasted multi-line queries back to per-line spans", () => {
|
||||
const renderData = createFilePaneTextRenderData("alpha\nbeta", "notes.txt");
|
||||
|
||||
const matches = findFilePaneTextMatches(renderData, "ha\nbe");
|
||||
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0]?.lineSpans).toEqual([
|
||||
{ lineNumber: 1, startColumn: 3, endColumn: 5 },
|
||||
{ lineNumber: 2, startColumn: 0, endColumn: 2 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFilePaneFindTokenSegments", () => {
|
||||
it("splits immutable token render data around match spans and marks the current match", () => {
|
||||
const line: FilePaneTextLineRenderData = {
|
||||
lineNumber: 1,
|
||||
text: "const answer",
|
||||
tokens: [
|
||||
{ text: "const", style: "keyword" },
|
||||
{ text: " answer", style: null },
|
||||
],
|
||||
};
|
||||
const highlights = createFilePaneLineFindHighlightMap(
|
||||
[
|
||||
{
|
||||
index: 0,
|
||||
startOffset: 2,
|
||||
endOffset: 8,
|
||||
lineSpans: [{ lineNumber: 1, startColumn: 2, endColumn: 8 }],
|
||||
},
|
||||
],
|
||||
0,
|
||||
);
|
||||
|
||||
const segments = createFilePaneFindTokenSegments(line, highlights.get(1) ?? []);
|
||||
|
||||
expect(segments).toEqual([
|
||||
{ text: "co", style: "keyword", isFindMatch: false, isCurrentFindMatch: false },
|
||||
{ text: "nst", style: "keyword", isFindMatch: true, isCurrentFindMatch: true },
|
||||
{ text: " an", style: null, isFindMatch: true, isCurrentFindMatch: true },
|
||||
{ text: "swer", style: null, isFindMatch: false, isCurrentFindMatch: false },
|
||||
]);
|
||||
expect(line.tokens).toEqual([
|
||||
{ text: "const", style: "keyword" },
|
||||
{ text: " answer", style: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
|
||||
interface FilePaneTextLineRenderData {
|
||||
lineNumber: number;
|
||||
text: string;
|
||||
tokens: HighlightToken[];
|
||||
}
|
||||
|
||||
interface FilePaneTextRenderData {
|
||||
lines: FilePaneTextLineRenderData[];
|
||||
searchableText: string;
|
||||
}
|
||||
|
||||
interface FilePaneFindLineSpan {
|
||||
lineNumber: number;
|
||||
startColumn: number;
|
||||
endColumn: number;
|
||||
}
|
||||
|
||||
export interface FilePaneFindMatch {
|
||||
index: number;
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
lineSpans: FilePaneFindLineSpan[];
|
||||
}
|
||||
|
||||
export interface FilePaneFindLineHighlight extends FilePaneFindLineSpan {
|
||||
matchIndex: number;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface FilePaneFindTokenSegment {
|
||||
text: string;
|
||||
style: HighlightToken["style"];
|
||||
isFindMatch: boolean;
|
||||
isCurrentFindMatch: boolean;
|
||||
}
|
||||
|
||||
export function createFilePaneTextRenderData(
|
||||
content: string,
|
||||
filePath: string,
|
||||
): FilePaneTextRenderData {
|
||||
const lines = highlightCode(content, filePath).map((tokens, index) => ({
|
||||
lineNumber: index + 1,
|
||||
text: tokens.map((token) => token.text).join(""),
|
||||
tokens,
|
||||
}));
|
||||
|
||||
return {
|
||||
lines,
|
||||
searchableText: lines.map((line) => line.text).join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export function findFilePaneTextMatches(
|
||||
renderData: FilePaneTextRenderData,
|
||||
query: string,
|
||||
): FilePaneFindMatch[] {
|
||||
if (query.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const searchableText = renderData.searchableText.toLocaleLowerCase();
|
||||
const normalizedQuery = query.toLocaleLowerCase();
|
||||
const matches: FilePaneFindMatch[] = [];
|
||||
let nextOffset = searchableText.indexOf(normalizedQuery);
|
||||
|
||||
while (nextOffset >= 0) {
|
||||
const endOffset = nextOffset + normalizedQuery.length;
|
||||
matches.push({
|
||||
index: matches.length,
|
||||
startOffset: nextOffset,
|
||||
endOffset,
|
||||
lineSpans: mapFilePaneTextRangeToLineSpans(renderData, nextOffset, endOffset),
|
||||
});
|
||||
nextOffset = searchableText.indexOf(normalizedQuery, endOffset);
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function createFilePaneLineFindHighlightMap(
|
||||
matches: FilePaneFindMatch[],
|
||||
currentMatchIndex: number,
|
||||
): Map<number, FilePaneFindLineHighlight[]> {
|
||||
const highlightsByLine = new Map<number, FilePaneFindLineHighlight[]>();
|
||||
|
||||
for (const match of matches) {
|
||||
for (const span of match.lineSpans) {
|
||||
const highlights = highlightsByLine.get(span.lineNumber) ?? [];
|
||||
highlights.push({
|
||||
...span,
|
||||
matchIndex: match.index,
|
||||
isCurrent: match.index === currentMatchIndex,
|
||||
});
|
||||
highlightsByLine.set(span.lineNumber, highlights);
|
||||
}
|
||||
}
|
||||
|
||||
for (const highlights of highlightsByLine.values()) {
|
||||
highlights.sort((left, right) => left.startColumn - right.startColumn);
|
||||
}
|
||||
|
||||
return highlightsByLine;
|
||||
}
|
||||
|
||||
export function createFilePaneFindTokenSegments(
|
||||
line: FilePaneTextLineRenderData,
|
||||
highlights: FilePaneFindLineHighlight[],
|
||||
): FilePaneFindTokenSegment[] {
|
||||
if (highlights.length === 0) {
|
||||
return line.tokens.map((token) => ({
|
||||
...token,
|
||||
isFindMatch: false,
|
||||
isCurrentFindMatch: false,
|
||||
}));
|
||||
}
|
||||
|
||||
const segments: FilePaneFindTokenSegment[] = [];
|
||||
let tokenStartColumn = 0;
|
||||
|
||||
for (const token of line.tokens) {
|
||||
let cursor = tokenStartColumn;
|
||||
const tokenEndColumn = tokenStartColumn + token.text.length;
|
||||
const tokenHighlights = highlights.filter(
|
||||
(highlight) =>
|
||||
highlight.startColumn < tokenEndColumn && highlight.endColumn > tokenStartColumn,
|
||||
);
|
||||
|
||||
for (const highlight of tokenHighlights) {
|
||||
const highlightStart = Math.max(highlight.startColumn, tokenStartColumn);
|
||||
const highlightEnd = Math.min(highlight.endColumn, tokenEndColumn);
|
||||
|
||||
if (cursor < highlightStart) {
|
||||
segments.push({
|
||||
text: token.text.slice(cursor - tokenStartColumn, highlightStart - tokenStartColumn),
|
||||
style: token.style,
|
||||
isFindMatch: false,
|
||||
isCurrentFindMatch: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (highlightStart < highlightEnd) {
|
||||
segments.push({
|
||||
text: token.text.slice(
|
||||
highlightStart - tokenStartColumn,
|
||||
highlightEnd - tokenStartColumn,
|
||||
),
|
||||
style: token.style,
|
||||
isFindMatch: true,
|
||||
isCurrentFindMatch: highlight.isCurrent,
|
||||
});
|
||||
}
|
||||
|
||||
cursor = highlightEnd;
|
||||
}
|
||||
|
||||
if (cursor < tokenEndColumn) {
|
||||
segments.push({
|
||||
text: token.text.slice(cursor - tokenStartColumn),
|
||||
style: token.style,
|
||||
isFindMatch: false,
|
||||
isCurrentFindMatch: false,
|
||||
});
|
||||
}
|
||||
|
||||
tokenStartColumn = tokenEndColumn;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function mapFilePaneTextRangeToLineSpans(
|
||||
renderData: FilePaneTextRenderData,
|
||||
startOffset: number,
|
||||
endOffset: number,
|
||||
): FilePaneFindLineSpan[] {
|
||||
const spans: FilePaneFindLineSpan[] = [];
|
||||
let lineStartOffset = 0;
|
||||
|
||||
for (const line of renderData.lines) {
|
||||
const lineEndOffset = lineStartOffset + line.text.length;
|
||||
const spanStartOffset = Math.max(startOffset, lineStartOffset);
|
||||
const spanEndOffset = Math.min(endOffset, lineEndOffset);
|
||||
|
||||
if (spanStartOffset < spanEndOffset) {
|
||||
spans.push({
|
||||
lineNumber: line.lineNumber,
|
||||
startColumn: spanStartOffset - lineStartOffset,
|
||||
endColumn: spanEndOffset - lineStartOffset,
|
||||
});
|
||||
}
|
||||
|
||||
lineStartOffset = lineEndOffset + 1;
|
||||
}
|
||||
|
||||
return spans;
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* @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 type { ExplorerFile } from "@/stores/session-store";
|
||||
import { FilePane } from "@/components/file-pane";
|
||||
import {
|
||||
PaneFocusProvider,
|
||||
PaneProvider,
|
||||
createPaneFocusContextValue,
|
||||
type PaneContextValue,
|
||||
} from "@/panels/pane-context";
|
||||
import {
|
||||
clearActivePaneFindPaneId,
|
||||
handlePaneFindKeyboardAction,
|
||||
setActivePaneFindPaneId,
|
||||
} from "@/panels/pane-find-registry";
|
||||
|
||||
const { queryState, theme } = vi.hoisted(() => ({
|
||||
queryState: {
|
||||
current: {
|
||||
data: null as null | {
|
||||
error: string | null;
|
||||
file: ExplorerFile | null;
|
||||
imageAttachment?: unknown;
|
||||
},
|
||||
isFetching: false,
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
|
||||
fontSize: { sm: 13, code: 13 },
|
||||
colors: {
|
||||
destructive: "#f43f5e",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
primary: "#0a84ff",
|
||||
surface0: "#111",
|
||||
surface1: "#222",
|
||||
surface2: "#333",
|
||||
},
|
||||
colorScheme: "dark",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => queryState.current,
|
||||
}));
|
||||
|
||||
vi.mock("react-native", () => {
|
||||
const MockView = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
{ children?: React.ReactNode; testID?: string }
|
||||
>(function View({ children, testID }, ref) {
|
||||
return React.createElement("div", { "data-testid": testID, ref }, children);
|
||||
});
|
||||
const MockScrollView = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
{ children?: React.ReactNode; horizontal?: boolean }
|
||||
>(function ScrollView({ children, horizontal }, ref) {
|
||||
return React.createElement(
|
||||
"div",
|
||||
{ "data-horizontal": horizontal ? "true" : undefined, ref },
|
||||
children,
|
||||
);
|
||||
});
|
||||
const flattenMockStyle = (value: unknown): React.CSSProperties => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.reduce<React.CSSProperties>(
|
||||
(acc, item) => Object.assign(acc, flattenMockStyle(item)),
|
||||
{},
|
||||
);
|
||||
}
|
||||
return (value as React.CSSProperties | null | undefined) ?? {};
|
||||
};
|
||||
const MockText = ({ children, style }: { children?: React.ReactNode; style?: unknown }) =>
|
||||
React.createElement("span", { style: flattenMockStyle(style) }, children);
|
||||
const MockPressable = ({
|
||||
children,
|
||||
disabled,
|
||||
onPress,
|
||||
testID,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onPress?: () => void;
|
||||
testID?: string;
|
||||
}) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
"data-testid": testID,
|
||||
disabled,
|
||||
onClick: () => {
|
||||
if (!disabled) {
|
||||
onPress?.();
|
||||
}
|
||||
},
|
||||
type: "button",
|
||||
},
|
||||
children,
|
||||
);
|
||||
const MockTextInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
{
|
||||
value?: string;
|
||||
onChangeText?: (text: string) => void;
|
||||
onKeyPress?: (event: {
|
||||
nativeEvent: { key: string; shiftKey?: boolean };
|
||||
preventDefault: () => void;
|
||||
}) => void;
|
||||
testID?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
>(function TextInput({ value, onChangeText, onKeyPress, testID, placeholder }, ref) {
|
||||
return React.createElement("input", {
|
||||
"data-testid": testID,
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
onChangeText?.(event.currentTarget.value),
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLInputElement>) =>
|
||||
onKeyPress?.({
|
||||
nativeEvent: { key: event.key, shiftKey: event.shiftKey },
|
||||
preventDefault: () => event.preventDefault(),
|
||||
}),
|
||||
placeholder,
|
||||
ref,
|
||||
value: value ?? "",
|
||||
});
|
||||
});
|
||||
const MockImage = ({ source }: { source?: { uri?: string } }) =>
|
||||
React.createElement("img", { alt: "", src: source?.uri ?? "" });
|
||||
|
||||
return {
|
||||
ActivityIndicator: () => React.createElement("span", { "data-testid": "activity" }),
|
||||
Image: MockImage,
|
||||
Platform: {
|
||||
OS: "web",
|
||||
select: (options: Record<string, unknown>) => options.web ?? options.default,
|
||||
},
|
||||
Pressable: MockPressable,
|
||||
ScrollView: MockScrollView,
|
||||
Text: MockText,
|
||||
TextInput: MockTextInput,
|
||||
View: MockView,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const createIcon = (name: string) =>
|
||||
function Icon() {
|
||||
return React.createElement("span", { "data-icon": name });
|
||||
};
|
||||
return {
|
||||
ChevronDown: createIcon("ChevronDown"),
|
||||
ChevronUp: createIcon("ChevronUp"),
|
||||
X: createIcon("X"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native-markdown-display", () => ({
|
||||
default: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("article", { "data-testid": "markdown-preview" }, children),
|
||||
MarkdownIt: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) =>
|
||||
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
||||
},
|
||||
useUnistyles: () => ({ theme }),
|
||||
}));
|
||||
|
||||
vi.mock("@/styles/syntax-token-styles", () => ({
|
||||
syntaxTokenStyleFor: () => ({ color: "#fff" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/attachments/use-attachment-preview-url", () => ({
|
||||
useAttachmentPreviewUrl: (metadata: unknown) => (metadata ? "blob:preview" : null),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/use-web-scrollbar", () => ({
|
||||
useWebScrollViewScrollbar: () => ({
|
||||
onContentSizeChange: vi.fn(),
|
||||
onLayout: vi.fn(),
|
||||
onScroll: vi.fn(),
|
||||
overlay: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-web-scrollbar-style", () => ({
|
||||
useWebScrollbarStyle: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/layout", () => ({
|
||||
useIsCompactFormFactor: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
isWeb: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/styles/markdown-styles", () => ({
|
||||
createMarkdownStyles: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/session-store", () => ({
|
||||
useSessionStore: (selector: (state: unknown) => unknown) => selector({ sessions: {} }),
|
||||
}));
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
const paneInstanceId = "server:workspace:file";
|
||||
const paneContext: PaneContextValue = {
|
||||
serverId: "server",
|
||||
workspaceId: "workspace",
|
||||
paneInstanceId,
|
||||
tabId: "file",
|
||||
target: { kind: "file", path: "src/example.ts" },
|
||||
openTab: () => {},
|
||||
closeCurrentTab: () => {},
|
||||
retargetCurrentTab: () => {},
|
||||
openFileInWorkspace: () => {},
|
||||
openImportSheet: () => {},
|
||||
};
|
||||
const paneFocus = createPaneFocusContextValue({
|
||||
isPaneFocused: true,
|
||||
isWorkspaceFocused: true,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
clearActivePaneFindPaneId(paneInstanceId);
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
queryState.current = { data: null, isFetching: false };
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderFilePane(location: { path: string } = { path: "src/example.ts" }) {
|
||||
act(() => {
|
||||
root?.render(
|
||||
<PaneProvider value={paneContext}>
|
||||
<PaneFocusProvider value={paneFocus}>
|
||||
<FilePane serverId="server" workspaceRoot="/repo" location={location} />
|
||||
</PaneFocusProvider>
|
||||
</PaneProvider>,
|
||||
);
|
||||
});
|
||||
setActivePaneFindPaneId(paneInstanceId);
|
||||
}
|
||||
|
||||
function inputElement(): HTMLInputElement {
|
||||
const input = container?.querySelector('[data-testid="pane-find-input"]');
|
||||
expect(input).toBeInstanceOf(HTMLInputElement);
|
||||
return input as HTMLInputElement;
|
||||
}
|
||||
|
||||
function changeInput(value: string): void {
|
||||
const input = inputElement();
|
||||
act(() => {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
valueSetter?.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function click(testId: string): void {
|
||||
const element = container?.querySelector(`[data-testid="${testId}"]`);
|
||||
expect(element).toBeInstanceOf(HTMLElement);
|
||||
act(() => {
|
||||
element?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function makeFile(file: Partial<ExplorerFile>): ExplorerFile {
|
||||
return {
|
||||
content: "",
|
||||
encoding: "utf-8",
|
||||
kind: "text",
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
path: file.path ?? "src/example.ts",
|
||||
size: file.size ?? 0,
|
||||
...file,
|
||||
};
|
||||
}
|
||||
|
||||
describe("FilePane preview rendering", () => {
|
||||
it("renders code/text previews with line numbers and highlighted token text", () => {
|
||||
queryState.current = {
|
||||
data: {
|
||||
error: null,
|
||||
file: makeFile({ content: "const answer = 42;\nconsole.log(answer);" }),
|
||||
},
|
||||
isFetching: false,
|
||||
};
|
||||
|
||||
renderFilePane();
|
||||
|
||||
const text = container?.textContent ?? "";
|
||||
expect(text).toContain("1");
|
||||
expect(text).toContain("2");
|
||||
expect(text).toContain("const answer = 42;");
|
||||
expect(text).toContain("console.log(answer);");
|
||||
});
|
||||
|
||||
it("keeps markdown files on the markdown preview path", () => {
|
||||
queryState.current = {
|
||||
data: {
|
||||
error: null,
|
||||
file: makeFile({ content: "# Guide", path: "README.md" }),
|
||||
},
|
||||
isFetching: false,
|
||||
};
|
||||
|
||||
renderFilePane({ path: "README.md" });
|
||||
|
||||
expect(container?.querySelector('[data-testid="markdown-preview"]')?.textContent).toBe(
|
||||
"# Guide",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders image previews from the attachment preview URL", () => {
|
||||
queryState.current = {
|
||||
data: {
|
||||
error: null,
|
||||
file: makeFile({ content: undefined, encoding: "none", kind: "image", path: "logo.png" }),
|
||||
imageAttachment: { id: "preview" },
|
||||
},
|
||||
isFetching: false,
|
||||
};
|
||||
|
||||
renderFilePane({ path: "logo.png" });
|
||||
|
||||
expect(container?.querySelector("img")?.getAttribute("src")).toBe("blob:preview");
|
||||
});
|
||||
|
||||
it("keeps binary previews on the unavailable state with file size", () => {
|
||||
queryState.current = {
|
||||
data: {
|
||||
error: null,
|
||||
file: makeFile({
|
||||
content: undefined,
|
||||
encoding: "none",
|
||||
kind: "binary",
|
||||
path: "tool",
|
||||
size: 2048,
|
||||
}),
|
||||
},
|
||||
isFetching: false,
|
||||
};
|
||||
|
||||
renderFilePane({ path: "tool" });
|
||||
|
||||
expect(container?.textContent).toContain("Binary preview unavailable");
|
||||
expect(container?.textContent).toContain("2.0 KB");
|
||||
});
|
||||
|
||||
it("searches code/text previews case-insensitively and navigates matches", () => {
|
||||
queryState.current = {
|
||||
data: {
|
||||
error: null,
|
||||
file: makeFile({ content: "const answer = 42;\nconsole.log(ANSWER);" }),
|
||||
},
|
||||
isFetching: false,
|
||||
};
|
||||
renderFilePane();
|
||||
|
||||
act(() => {
|
||||
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
|
||||
});
|
||||
changeInput("answer");
|
||||
|
||||
expect(container?.textContent).toContain("1 / 2");
|
||||
expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes(1);
|
||||
expect(container?.querySelectorAll('span[style*="background-color"]').length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
|
||||
click("pane-find-next");
|
||||
expect(container?.textContent).toContain("2 / 2");
|
||||
expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes(2);
|
||||
|
||||
click("pane-find-prev");
|
||||
expect(container?.textContent).toContain("1 / 2");
|
||||
expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("clears file highlights on empty query and close", () => {
|
||||
queryState.current = {
|
||||
data: {
|
||||
error: null,
|
||||
file: makeFile({ content: "needle\nneedle" }),
|
||||
},
|
||||
isFetching: false,
|
||||
};
|
||||
renderFilePane();
|
||||
|
||||
act(() => {
|
||||
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
|
||||
});
|
||||
changeInput("needle");
|
||||
expect(container?.textContent).toContain("1 / 2");
|
||||
|
||||
changeInput("");
|
||||
expect(container?.textContent).toContain("0 / 0");
|
||||
expect(container?.querySelectorAll('span[style*="background-color"]').length).toBe(0);
|
||||
|
||||
changeInput("needle");
|
||||
click("pane-find-close");
|
||||
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
|
||||
expect(container?.querySelectorAll('span[style*="background-color"]').length).toBe(0);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,6 @@ import type { HighlightToken } from "@getpaseo/highlight";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { highlightToKeyedLines, type KeyedLine } from "@/utils/highlight-cache";
|
||||
|
||||
interface HighlightedCodeBlockProps {
|
||||
@@ -76,7 +75,6 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({
|
||||
return (
|
||||
<View
|
||||
style={containerStyle}
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import type { KeyedLine, KeyedToken } from "@/utils/highlight-cache";
|
||||
|
||||
@@ -56,7 +56,7 @@ const GutteredLine = React.memo(function GutteredLine({
|
||||
export function HighlightedLines({ lines, startLine }: HighlightedLinesProps) {
|
||||
if (startLine === undefined) {
|
||||
return (
|
||||
<View dataSet={CODE_SURFACE_DATASET}>
|
||||
<View>
|
||||
{lines.map((line) => (
|
||||
<ContentLine key={line.key} line={line} />
|
||||
))}
|
||||
@@ -67,7 +67,7 @@ export function HighlightedLines({ lines, startLine }: HighlightedLinesProps) {
|
||||
const lastLineNumber = startLine + lines.length - 1;
|
||||
const digits = Math.max(2, String(lastLineNumber).length);
|
||||
return (
|
||||
<View dataSet={CODE_SURFACE_DATASET}>
|
||||
<View>
|
||||
{lines.map((line, index) => (
|
||||
<GutteredLine key={line.key} line={line} lineNumber={startLine + index} digits={digits} />
|
||||
))}
|
||||
@@ -81,7 +81,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minHeight: CODE_LINE_HEIGHT,
|
||||
},
|
||||
gutterText: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: CODE_LINE_HEIGHT,
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -90,7 +90,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 0,
|
||||
},
|
||||
lineText: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: CODE_LINE_HEIGHT,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { SquareTerminal } from "lucide-react-native";
|
||||
import { useMemo } from "react";
|
||||
import { Image, type ImageSourcePropType } from "react-native";
|
||||
import { isKnownEditorTargetId, type EditorTargetId } from "@/workspace/editor-targets";
|
||||
import {
|
||||
isKnownEditorTargetId,
|
||||
type EditorTargetId,
|
||||
type KnownEditorTargetId,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
interface EditorAppIconProps {
|
||||
editorId: EditorTargetId;
|
||||
@@ -10,7 +14,7 @@ interface EditorAppIconProps {
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const EDITOR_APP_IMAGES: Record<string, ImageSourcePropType> = {
|
||||
const EDITOR_APP_IMAGES: Record<KnownEditorTargetId, ImageSourcePropType> = {
|
||||
cursor: require("../../../assets/images/editor-apps/cursor.png"),
|
||||
vscode: require("../../../assets/images/editor-apps/vscode.png"),
|
||||
webstorm: require("../../../assets/images/editor-apps/webstorm.png"),
|
||||
@@ -21,16 +25,15 @@ const EDITOR_APP_IMAGES: Record<string, ImageSourcePropType> = {
|
||||
};
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
|
||||
export function hasBundledEditorAppIcon(editorId: EditorTargetId): boolean {
|
||||
return isKnownEditorTargetId(editorId) && EDITOR_APP_IMAGES[editorId] !== undefined;
|
||||
export function hasBundledEditorAppIcon(editorId: EditorTargetId): editorId is KnownEditorTargetId {
|
||||
return isKnownEditorTargetId(editorId);
|
||||
}
|
||||
|
||||
export function EditorAppIcon({ editorId, size = 16, color }: EditorAppIconProps) {
|
||||
const imageStyle = useMemo(() => ({ width: size, height: size }), [size]);
|
||||
const source = EDITOR_APP_IMAGES[editorId];
|
||||
if (!source) {
|
||||
if (!hasBundledEditorAppIcon(editorId)) {
|
||||
return <SquareTerminal size={size} color={color} />;
|
||||
}
|
||||
|
||||
return <Image source={source} style={imageStyle} resizeMode="contain" />;
|
||||
return <Image source={EDITOR_APP_IMAGES[editorId]} style={imageStyle} resizeMode="contain" />;
|
||||
}
|
||||
|
||||
@@ -51,20 +51,20 @@ describe("resolveProvidersToFetch", () => {
|
||||
expect(resolveProvidersToFetch(true, undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns enabled providers", () => {
|
||||
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", "z-ai"]);
|
||||
expect(providers).toEqual(["claude", "codex"]);
|
||||
});
|
||||
|
||||
it("returns an empty array when snapshot has no enabled providers", () => {
|
||||
it("returns an empty array when snapshot has no enabled importable providers", () => {
|
||||
const providers = resolveProvidersToFetch(true, [
|
||||
{ provider: "claude", enabled: false },
|
||||
{ provider: "z-ai", enabled: false },
|
||||
{ provider: "z-ai" },
|
||||
]);
|
||||
expect(providers).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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__";
|
||||
|
||||
@@ -26,7 +28,9 @@ export function resolveProvidersToFetch(
|
||||
// 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) => entry.enabled !== false).map((entry) => entry.provider);
|
||||
return snapshotEntries
|
||||
.filter((entry) => IMPORTABLE_PROVIDER_IDS.has(entry.provider) && entry.enabled !== false)
|
||||
.map((entry) => entry.provider);
|
||||
}
|
||||
|
||||
export function buildProviderLabelMap(
|
||||
|
||||
@@ -541,7 +541,7 @@ describe("ImportSessionSheet", () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fans out one request per enabled provider when snapshot is supported", async () => {
|
||||
it("fans out one request per enabled importable provider when snapshot is supported", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(
|
||||
async (options: { providers?: string[] } | undefined) => ({
|
||||
requestId: `recent-${options?.providers?.[0] ?? "all"}`,
|
||||
@@ -591,15 +591,12 @@ describe("ImportSessionSheet", () => {
|
||||
expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providers: ["opencode"] }),
|
||||
);
|
||||
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
|
||||
cwd: "/repo/paseo",
|
||||
providers: ["z-ai"],
|
||||
limit: 15,
|
||||
});
|
||||
expect(fetchRecentProviderSessions).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providers: ["z-ai"] }),
|
||||
);
|
||||
|
||||
await screen.findByText("Session claude");
|
||||
await screen.findByText("Session codex");
|
||||
await screen.findByText("Session z-ai");
|
||||
});
|
||||
|
||||
it("shows partial-failure note when one provider request fails but others succeed", async () => {
|
||||
@@ -719,7 +716,7 @@ describe("ImportSessionSheet", () => {
|
||||
expect(screen.queryByTestId("import-session-filter-all")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a no-importable-providers message when snapshot has no enabled providers", async () => {
|
||||
it("shows a no-importable-providers message when snapshot has no enabled importable providers", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn();
|
||||
const importAgent = vi.fn();
|
||||
|
||||
@@ -735,7 +732,7 @@ describe("ImportSessionSheet", () => {
|
||||
createSnapshotEntry("claude", { enabled: false }),
|
||||
createSnapshotEntry("codex", { enabled: false }),
|
||||
createSnapshotEntry("opencode", { enabled: false }),
|
||||
createSnapshotEntry("z-ai", { enabled: false }),
|
||||
createSnapshotEntry("z-ai"),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { FolderPlus, Home, MessagesSquare, Plus, Search, Settings, X } from "lucide-react-native";
|
||||
import { FolderPlus, Home, MessagesSquare, Settings, X } from "lucide-react-native";
|
||||
import {
|
||||
type Dispatch,
|
||||
memo,
|
||||
@@ -34,17 +34,12 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { SidebarGroupingSelector } from "@/components/sidebar/sidebar-grouping-selector";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import {
|
||||
MOBILE_VISUAL_PANEL_AGENT,
|
||||
MOBILE_VISUAL_PANEL_AGENT_LIST,
|
||||
useSidebarAnimation,
|
||||
} from "@/contexts/sidebar-animation-context";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
|
||||
@@ -52,8 +47,6 @@ import {
|
||||
type SidebarProjectEntry,
|
||||
useSidebarWorkspacesList,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||
import {
|
||||
MAX_SIDEBAR_WIDTH,
|
||||
@@ -66,7 +59,6 @@ import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import {
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostNewWorkspaceRoute,
|
||||
buildHostSessionsRoute,
|
||||
buildSettingsRoute,
|
||||
mapPathnameToServer,
|
||||
@@ -97,13 +89,11 @@ interface SidebarSharedProps {
|
||||
isInitialLoad: boolean;
|
||||
isRevalidating: boolean;
|
||||
isManualRefresh: boolean;
|
||||
groupMode: SidebarGroupMode;
|
||||
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
|
||||
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
|
||||
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
|
||||
handleRefresh: () => void;
|
||||
handleHostSelect: (nextServerId: string) => void;
|
||||
handleNewWorkspaceNavigate: () => void;
|
||||
handleOpenProject: () => void;
|
||||
handleHome: () => void;
|
||||
handleSettings: () => void;
|
||||
@@ -119,7 +109,7 @@ interface MobileSidebarProps extends SidebarSharedProps {
|
||||
insetsTop: number;
|
||||
insetsBottom: number;
|
||||
isOpen: boolean;
|
||||
closeSidebar: () => void;
|
||||
closeToAgent: () => void;
|
||||
handleViewMoreNavigate: () => void;
|
||||
}
|
||||
|
||||
@@ -202,10 +192,6 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||
useSidebarShortcutModel({ projects, isInitialLoad });
|
||||
|
||||
const groupMode = useSidebarViewStore((state) =>
|
||||
activeServerId ? state.getGroupMode(activeServerId) : "project",
|
||||
);
|
||||
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
@@ -230,11 +216,6 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
void openProjectPicker();
|
||||
}, [openProjectPicker]);
|
||||
|
||||
const handleNewWorkspaceNavigate = useCallback(() => {
|
||||
if (!activeServerId) return;
|
||||
router.push(buildHostNewWorkspaceRoute(activeServerId));
|
||||
}, [activeServerId]);
|
||||
|
||||
const handleSettingsMobile = useCallback(() => {
|
||||
showMobileAgent();
|
||||
router.push(buildSettingsRoute());
|
||||
@@ -287,7 +268,6 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
isManualRefresh,
|
||||
groupMode,
|
||||
collapsedProjectKeys,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
toggleProjectCollapsed,
|
||||
@@ -303,8 +283,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
isOpen={isOpen}
|
||||
closeSidebar={showMobileAgent}
|
||||
handleNewWorkspaceNavigate={handleNewWorkspaceNavigate}
|
||||
closeToAgent={showMobileAgent}
|
||||
handleOpenProject={handleOpenProjectMobile}
|
||||
handleHome={handleHomeMobile}
|
||||
handleSettings={handleSettingsMobile}
|
||||
@@ -318,7 +297,6 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
isOpen={isOpen}
|
||||
handleNewWorkspaceNavigate={handleNewWorkspaceNavigate}
|
||||
handleOpenProject={handleOpenProjectDesktop}
|
||||
handleHome={handleHomeDesktop}
|
||||
handleSettings={handleSettingsDesktop}
|
||||
@@ -439,21 +417,6 @@ function AddProjectTooltipContent({
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderIconTooltipContent({
|
||||
label,
|
||||
shortcutKeys,
|
||||
}: {
|
||||
label: string;
|
||||
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>{label}</Text>
|
||||
{shortcutKeys ? <Shortcut chord={shortcutKeys} /> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarFooter({
|
||||
theme,
|
||||
activeServerId,
|
||||
@@ -555,21 +518,19 @@ function MobileSidebar({
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
isManualRefresh,
|
||||
groupMode,
|
||||
collapsedProjectKeys,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
toggleProjectCollapsed,
|
||||
handleRefresh,
|
||||
handleHostSelect,
|
||||
renderHostOption,
|
||||
handleNewWorkspaceNavigate,
|
||||
handleOpenProject,
|
||||
handleHome,
|
||||
handleSettings,
|
||||
insetsTop,
|
||||
insetsBottom,
|
||||
isOpen,
|
||||
closeSidebar,
|
||||
closeToAgent,
|
||||
handleViewMoreNavigate,
|
||||
}: MobileSidebarProps) {
|
||||
const pathname = usePathname();
|
||||
@@ -581,7 +542,6 @@ function MobileSidebar({
|
||||
animateToOpen,
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
mobileVisualPanel,
|
||||
gestureAnimatingRef,
|
||||
closeGestureRef,
|
||||
} = useSidebarAnimation();
|
||||
@@ -590,8 +550,8 @@ function MobileSidebar({
|
||||
|
||||
const handleCloseFromGesture = useCallback(() => {
|
||||
gestureAnimatingRef.current = true;
|
||||
closeSidebar();
|
||||
}, [closeSidebar, gestureAnimatingRef]);
|
||||
closeToAgent();
|
||||
}, [closeToAgent, gestureAnimatingRef]);
|
||||
|
||||
const handleViewMore = useCallback(() => {
|
||||
if (!activeServerId) {
|
||||
@@ -599,31 +559,26 @@ function MobileSidebar({
|
||||
}
|
||||
translateX.value = -windowWidth;
|
||||
backdropOpacity.value = 0;
|
||||
closeSidebar();
|
||||
closeToAgent();
|
||||
handleViewMoreNavigate();
|
||||
}, [
|
||||
activeServerId,
|
||||
backdropOpacity,
|
||||
closeSidebar,
|
||||
closeToAgent,
|
||||
handleViewMoreNavigate,
|
||||
translateX,
|
||||
windowWidth,
|
||||
]);
|
||||
|
||||
const handleWorkspacePress = useCallback(() => {
|
||||
closeSidebar();
|
||||
}, [closeSidebar]);
|
||||
|
||||
const handleNewWorkspace = useCallback(() => {
|
||||
closeSidebar();
|
||||
handleNewWorkspaceNavigate();
|
||||
}, [closeSidebar, handleNewWorkspaceNavigate]);
|
||||
closeToAgent();
|
||||
}, [closeToAgent]);
|
||||
|
||||
const closeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.withRef(closeGestureRef)
|
||||
.enabled(true)
|
||||
.enabled(isOpen)
|
||||
.manualActivation(true)
|
||||
.onTouchesDown((event) => {
|
||||
const touch = event.changedTouches[0];
|
||||
@@ -645,11 +600,6 @@ function MobileSidebar({
|
||||
const absDeltaX = Math.abs(deltaX);
|
||||
const absDeltaY = Math.abs(deltaY);
|
||||
|
||||
if (mobileVisualPanel.value !== MOBILE_VISUAL_PANEL_AGENT_LIST) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaX >= 10) {
|
||||
stateManager.fail();
|
||||
return;
|
||||
@@ -679,11 +629,9 @@ function MobileSidebar({
|
||||
isGesturing.value = false;
|
||||
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
||||
if (shouldClose) {
|
||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT;
|
||||
animateToClose();
|
||||
runOnJS(handleCloseFromGesture)();
|
||||
} else {
|
||||
mobileVisualPanel.value = MOBILE_VISUAL_PANEL_AGENT_LIST;
|
||||
animateToOpen();
|
||||
}
|
||||
})
|
||||
@@ -691,11 +639,11 @@ function MobileSidebar({
|
||||
isGesturing.value = false;
|
||||
}),
|
||||
[
|
||||
isOpen,
|
||||
closeGestureRef,
|
||||
closeTouchStartX,
|
||||
closeTouchStartY,
|
||||
isGesturing,
|
||||
mobileVisualPanel,
|
||||
windowWidth,
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -750,22 +698,16 @@ function MobileSidebar({
|
||||
<GestureDetector gesture={closeGesture} touchAction="pan-y">
|
||||
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
label="Sessions"
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
/>
|
||||
</View>
|
||||
<WorkspacesSectionHeader
|
||||
serverId={activeServerId}
|
||||
onNewWorkspacePress={handleNewWorkspace}
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
label="Sessions"
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
/>
|
||||
<Pressable
|
||||
style={styles.mobileCloseButton}
|
||||
onPress={closeSidebar}
|
||||
onPress={closeToAgent}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
accessible
|
||||
@@ -791,7 +733,6 @@ function MobileSidebar({
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
projects={projects}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
@@ -836,14 +777,12 @@ function DesktopSidebar({
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
isManualRefresh,
|
||||
groupMode,
|
||||
collapsedProjectKeys,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
toggleProjectCollapsed,
|
||||
handleRefresh,
|
||||
handleHostSelect,
|
||||
renderHostOption,
|
||||
handleNewWorkspaceNavigate,
|
||||
handleOpenProject,
|
||||
handleHome,
|
||||
handleSettings,
|
||||
@@ -921,20 +860,14 @@ function DesktopSidebar({
|
||||
<View style={styles.sidebarDragArea}>
|
||||
<TitlebarDragRegion />
|
||||
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
label="Sessions"
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
/>
|
||||
</View>
|
||||
<SidebarHeaderRow
|
||||
icon={MessagesSquare}
|
||||
label="Sessions"
|
||||
onPress={handleViewMore}
|
||||
isActive={isSessionsActive}
|
||||
testID="sidebar-sessions"
|
||||
/>
|
||||
</View>
|
||||
<WorkspacesSectionHeader
|
||||
serverId={activeServerId}
|
||||
onNewWorkspacePress={handleNewWorkspaceNavigate}
|
||||
/>
|
||||
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
@@ -944,7 +877,6 @@ function DesktopSidebar({
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
groupMode={groupMode}
|
||||
projects={projects}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
@@ -979,90 +911,6 @@ function DesktopSidebar({
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspacesSectionHeader({
|
||||
serverId,
|
||||
onNewWorkspacePress,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
onNewWorkspacePress: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
|
||||
const commandCenterKeys = useShortcutKeys("toggle-command-center");
|
||||
const handleSearchPress = useCallback(() => setCommandCenterOpen(true), [setCommandCenterOpen]);
|
||||
const searchButtonStyle = useCallback(
|
||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.workspacesHeaderIconButton,
|
||||
(hovered || pressed) && styles.workspacesHeaderIconButtonHovered,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.workspacesSectionHeader}>
|
||||
<Text style={styles.workspacesSectionTitle}>Workspaces</Text>
|
||||
<View style={styles.workspacesSectionActions}>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="New workspace"
|
||||
testID="sidebar-new-workspace"
|
||||
style={searchButtonStyle}
|
||||
onPress={onNewWorkspacePress}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<Plus
|
||||
size={14}
|
||||
color={
|
||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<HeaderIconTooltipContent label="New workspace" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Open command center"
|
||||
testID="sidebar-command-center-search"
|
||||
style={searchButtonStyle}
|
||||
onPress={handleSearchPress}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<Search
|
||||
size={14}
|
||||
color={
|
||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<HeaderIconTooltipContent label="Search" shortcutKeys={commandCenterKeys} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<View>
|
||||
<SidebarGroupingSelector serverId={serverId} />
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<HeaderIconTooltipContent label="Display preferences" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
|
||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||
// tries to patch the native node that Reanimated also manages.
|
||||
@@ -1084,39 +932,6 @@ const staticStyles = RNStyleSheet.create({
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
sidebarHeaderRow: {
|
||||
position: "relative",
|
||||
},
|
||||
workspacesSectionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[2] + theme.spacing[3],
|
||||
paddingRight: theme.spacing[4],
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
workspacesSectionTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
workspacesSectionActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
workspacesHeaderIconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
workspacesHeaderIconButtonHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
sidebarContent: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { StyleSheet, type TextStyle } from "react-native";
|
||||
import { resolvePlainMarkdownTextStyle } from "@/components/markdown-text-style";
|
||||
|
||||
function unistylesStyle(id: string, style: Record<string, unknown>) {
|
||||
return {
|
||||
...style,
|
||||
[`unistyles_${id}`]: { id },
|
||||
};
|
||||
}
|
||||
|
||||
function uiTextViewFlatten(rootStyle: TextStyle, style: TextStyle): Record<string, unknown> {
|
||||
// react-native-uitextview/src/util.ts:8 flattens [rootStyle, style]
|
||||
// before passing the result to its native View-backed components.
|
||||
return { ...(StyleSheet.flatten([rootStyle, style]) as Record<string, unknown>) };
|
||||
}
|
||||
|
||||
function unistylesMetadataKeys(style: Record<string, unknown>) {
|
||||
return Object.keys(style).filter((key) => key.startsWith("unistyles_"));
|
||||
}
|
||||
|
||||
describe("resolvePlainMarkdownTextStyle", () => {
|
||||
it("keeps UITextView from collapsing parent and child Unistyles styles into one native View style object", () => {
|
||||
const merged = uiTextViewFlatten(
|
||||
resolvePlainMarkdownTextStyle(unistylesStyle("paragraph", { color: "#111" })),
|
||||
resolvePlainMarkdownTextStyle(unistylesStyle("text", { fontWeight: "600" })),
|
||||
);
|
||||
|
||||
expect(unistylesMetadataKeys(merged)).toHaveLength(0);
|
||||
expect(merged).toMatchObject({ color: "#111", fontWeight: "600" });
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { StyleSheet, type StyleProp, type TextStyle } from "react-native";
|
||||
|
||||
export function resolvePlainMarkdownTextStyle(style: StyleProp<TextStyle>): TextStyle {
|
||||
return stripUnistylesMetadata(StyleSheet.flatten(style) ?? {});
|
||||
}
|
||||
|
||||
function stripUnistylesMetadata(style: TextStyle): TextStyle {
|
||||
// iOS markdown text goes through react-native-uitextview. That library
|
||||
// inherits text styles by flattening [parentStyle, childStyle] before handing
|
||||
// the result to native View-backed components. If both entries are Unistyles
|
||||
// styles, flattening preserves both `unistyles_*` metadata keys in one object,
|
||||
// and Unistyles correctly warns that the style should have stayed array-shaped.
|
||||
//
|
||||
// `UITextView` is a third-party boundary, not a Unistyles-tracked component in
|
||||
// our ownership model. Resolve the concrete style values before crossing that
|
||||
// boundary and drop only Unistyles' private tracking metadata. This preserves
|
||||
// iOS paragraph/spanning text selection while avoiding the metadata merge.
|
||||
const plainStyle: Record<string, unknown> = { ...style };
|
||||
for (const key of Object.keys(plainStyle)) {
|
||||
if (key.startsWith("unistyles_")) {
|
||||
delete plainStyle[key];
|
||||
}
|
||||
}
|
||||
return plainStyle as TextStyle;
|
||||
}
|
||||
@@ -1,33 +1,17 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextProps,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
|
||||
interface MarkdownTextSpanProps {
|
||||
style?: StyleProp<TextStyle>;
|
||||
monoSurface?: boolean;
|
||||
children: ReactNode;
|
||||
onPress?: TextProps["onPress"];
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
}
|
||||
|
||||
// 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). onPress works
|
||||
// natively here, so links routed through this span stay tappable on Android.
|
||||
export function MarkdownTextSpan({
|
||||
style,
|
||||
children,
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
}: MarkdownTextSpanProps) {
|
||||
// (that requires a single UITextView ancestor and is iOS-only).
|
||||
export function MarkdownTextSpan({ style, children }: MarkdownTextSpanProps) {
|
||||
return (
|
||||
<Text selectable style={style} onPress={onPress} accessibilityRole={accessibilityRole}>
|
||||
<Text selectable style={style}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
@@ -35,7 +19,6 @@ export function MarkdownTextSpan({
|
||||
|
||||
interface MarkdownParagraphViewProps {
|
||||
paragraphStyle: ViewStyle;
|
||||
containsImage?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,43 +1,19 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { View, type StyleProp, type TextProps, type TextStyle, type ViewStyle } from "react-native";
|
||||
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
|
||||
import { UITextView } from "react-native-uitextview";
|
||||
import { resolvePlainMarkdownTextStyle } from "@/components/markdown-text-style";
|
||||
|
||||
interface MarkdownTextSpanProps {
|
||||
style?: StyleProp<TextStyle>;
|
||||
monoSurface?: boolean;
|
||||
children: ReactNode;
|
||||
// Links route through this span too (see assistant-file-links/link.tsx). A
|
||||
// plain <Text> nested in the paragraph UITextView is dropped, so the link
|
||||
// must be a UITextView span to be visible. onPress is wired onto the leaf
|
||||
// string children here: react-native-uitextview attaches it to the
|
||||
// RNUITextViewChild nodes it builds from string content, which the native tap
|
||||
// recognizer dispatches to. The link's handler reaches these leaf spans via
|
||||
// AssistantLinkPressProvider (see assistant-file-links/link-press-context).
|
||||
onPress?: TextProps["onPress"];
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
}
|
||||
|
||||
// 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,
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
}: MarkdownTextSpanProps) {
|
||||
const plainStyle = useMemo(() => resolvePlainMarkdownTextStyle(style), [style]);
|
||||
|
||||
export function MarkdownTextSpan({ style, children }: MarkdownTextSpanProps) {
|
||||
return (
|
||||
<UITextView
|
||||
uiTextView
|
||||
selectable
|
||||
style={plainStyle}
|
||||
onPress={onPress}
|
||||
accessibilityRole={accessibilityRole}
|
||||
>
|
||||
<UITextView uiTextView selectable style={style}>
|
||||
{children}
|
||||
</UITextView>
|
||||
);
|
||||
@@ -45,7 +21,6 @@ export function MarkdownTextSpan({
|
||||
|
||||
interface MarkdownParagraphViewProps {
|
||||
paragraphStyle: ViewStyle;
|
||||
containsImage?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -57,27 +32,13 @@ const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
|
||||
// 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,
|
||||
containsImage = false,
|
||||
children,
|
||||
}: MarkdownParagraphViewProps) {
|
||||
const textStyle = useMemo(
|
||||
() =>
|
||||
resolvePlainMarkdownTextStyle([
|
||||
paragraphStyle,
|
||||
MARKDOWN_PARAGRAPH_RESET,
|
||||
] as StyleProp<TextStyle>),
|
||||
export function MarkdownParagraphView({ paragraphStyle, children }: MarkdownParagraphViewProps) {
|
||||
const style = useMemo(
|
||||
() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET] as StyleProp<TextStyle>,
|
||||
[paragraphStyle],
|
||||
);
|
||||
const viewStyle = useMemo(() => [paragraphStyle, MARKDOWN_PARAGRAPH_RESET], [paragraphStyle]);
|
||||
|
||||
if (containsImage) {
|
||||
return <View style={viewStyle}>{children}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
<UITextView uiTextView selectable style={textStyle}>
|
||||
<UITextView uiTextView selectable style={style}>
|
||||
{children}
|
||||
</UITextView>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,9 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextProps,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
|
||||
interface MarkdownTextSpanProps {
|
||||
style?: StyleProp<TextStyle>;
|
||||
monoSurface?: boolean;
|
||||
children: ReactNode;
|
||||
// Web links use the <a>/Pressable path in link.tsx, not this span, so these
|
||||
// are accepted for prop-shape parity with the native variants and forwarded
|
||||
// harmlessly.
|
||||
onPress?: TextProps["onPress"];
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
}
|
||||
|
||||
// react-native-web renders Text as <span>/<div> with `user-select: text`
|
||||
@@ -25,28 +11,12 @@ interface MarkdownTextSpanProps {
|
||||
// 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,
|
||||
monoSurface,
|
||||
children,
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
}: MarkdownTextSpanProps) {
|
||||
return (
|
||||
<Text
|
||||
dataSet={monoSurface ? CODE_SURFACE_DATASET : undefined}
|
||||
style={style}
|
||||
onPress={onPress}
|
||||
accessibilityRole={accessibilityRole}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
export function MarkdownTextSpan({ style, children }: MarkdownTextSpanProps) {
|
||||
return <Text style={style}>{children}</Text>;
|
||||
}
|
||||
|
||||
interface MarkdownParagraphViewProps {
|
||||
paragraphStyle: ViewStyle;
|
||||
containsImage?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type TextStyle,
|
||||
} from "react-native";
|
||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import * as React from "react";
|
||||
import {
|
||||
useState,
|
||||
@@ -63,14 +62,13 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
||||
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, getMarkdownListSpacing } from "@/utils/markdown-list";
|
||||
import { markdownNodeContainsType } from "@/utils/markdown-ast";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||
@@ -100,7 +98,6 @@ import {
|
||||
AssistantMarkdownLink,
|
||||
type InlinePathTarget,
|
||||
useAssistantFileLinkActions,
|
||||
useAssistantLinkPress,
|
||||
} from "@/assistant-file-links";
|
||||
import { getCompactionMarkerLabel } from "./message-compaction-label";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
@@ -127,6 +124,7 @@ interface UserMessageProps {
|
||||
isFirstInGroup?: boolean;
|
||||
isLastInGroup?: boolean;
|
||||
disableOuterSpacing?: boolean;
|
||||
findHighlights?: MessageFindHighlight[];
|
||||
}
|
||||
|
||||
const MessageOuterSpacingContext = createContext(false);
|
||||
@@ -161,6 +159,19 @@ const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [
|
||||
] as const;
|
||||
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>;
|
||||
|
||||
export interface MessageFindHighlight {
|
||||
id: string;
|
||||
start: number;
|
||||
end: number;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
interface MessageFindTextSegment {
|
||||
key: string;
|
||||
text: string;
|
||||
highlight?: MessageFindHighlight;
|
||||
}
|
||||
|
||||
interface MarkdownWithStableRendererProps {
|
||||
children: ReactNode;
|
||||
style: ReturnType<typeof createMarkdownStyles>;
|
||||
@@ -212,6 +223,95 @@ const SCROLL_EDGE_EPSILON = 0.5;
|
||||
export const STREAM_METADATA_FONT_SIZE = 13;
|
||||
type ScrollAxis = "x" | "y";
|
||||
|
||||
function normalizeFindHighlights(
|
||||
textLength: number,
|
||||
highlights: MessageFindHighlight[] | undefined,
|
||||
): MessageFindHighlight[] {
|
||||
if (!highlights || highlights.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return highlights
|
||||
.map((highlight) => ({
|
||||
...highlight,
|
||||
start: Math.max(0, Math.min(textLength, highlight.start)),
|
||||
end: Math.max(0, Math.min(textLength, highlight.end)),
|
||||
}))
|
||||
.filter((highlight) => highlight.start < highlight.end)
|
||||
.sort((left, right) => left.start - right.start || left.end - right.end);
|
||||
}
|
||||
|
||||
function createMessageFindTextSegments(
|
||||
text: string,
|
||||
highlights: MessageFindHighlight[] | undefined,
|
||||
): MessageFindTextSegment[] {
|
||||
const normalizedHighlights = normalizeFindHighlights(text.length, highlights);
|
||||
if (normalizedHighlights.length === 0) {
|
||||
return [{ key: "text", text }];
|
||||
}
|
||||
|
||||
const segments: MessageFindTextSegment[] = [];
|
||||
let cursor = 0;
|
||||
for (const highlight of normalizedHighlights) {
|
||||
if (cursor < highlight.start) {
|
||||
segments.push({
|
||||
key: `plain:${cursor}:${highlight.start}`,
|
||||
text: text.slice(cursor, highlight.start),
|
||||
});
|
||||
}
|
||||
segments.push({
|
||||
key: highlight.id,
|
||||
text: text.slice(highlight.start, highlight.end),
|
||||
highlight,
|
||||
});
|
||||
cursor = highlight.end;
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
segments.push({
|
||||
key: `plain:${cursor}:${text.length}`,
|
||||
text: text.slice(cursor),
|
||||
});
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
interface MarkdownBlockWithOffset {
|
||||
key: string;
|
||||
block: string;
|
||||
startOffset: number;
|
||||
}
|
||||
|
||||
function createMarkdownBlocksWithOffsets(message: string): MarkdownBlockWithOffset[] {
|
||||
let cursor = 0;
|
||||
return splitMarkdownBlocks(message).map((block, index) => {
|
||||
const startOffset = Math.max(cursor, message.indexOf(block, cursor));
|
||||
cursor = startOffset + block.length;
|
||||
return {
|
||||
key: `${index}:${block.slice(0, 32)}`,
|
||||
block,
|
||||
startOffset,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createBlockFindHighlights(
|
||||
blockStartOffset: number,
|
||||
blockLength: number,
|
||||
highlights: MessageFindHighlight[] | undefined,
|
||||
): MessageFindHighlight[] {
|
||||
const blockEndOffset = blockStartOffset + blockLength;
|
||||
return normalizeFindHighlights(blockEndOffset, highlights)
|
||||
.filter((highlight) => highlight.start < blockEndOffset && highlight.end > blockStartOffset)
|
||||
.map((highlight) => ({
|
||||
id: highlight.id,
|
||||
start: Math.max(0, highlight.start - blockStartOffset),
|
||||
end: Math.min(blockLength, highlight.end - blockStartOffset),
|
||||
isCurrent: highlight.isCurrent,
|
||||
}));
|
||||
}
|
||||
|
||||
function ensureWebToolCallShimmerKeyframes() {
|
||||
if (isNative) {
|
||||
return;
|
||||
@@ -372,6 +472,14 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
lineHeight: 22,
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
findMatchText: {
|
||||
backgroundColor:
|
||||
theme.colorScheme === "dark" ? "rgba(250, 204, 21, 0.32)" : "rgba(250, 204, 21, 0.38)",
|
||||
},
|
||||
findCurrentMatchText: {
|
||||
backgroundColor:
|
||||
theme.colorScheme === "dark" ? "rgba(251, 146, 60, 0.58)" : "rgba(251, 146, 60, 0.48)",
|
||||
},
|
||||
imagePreviewContainer: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
@@ -439,6 +547,28 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function MessageFindHighlightedText({ segments }: { segments: MessageFindTextSegment[] }) {
|
||||
return (
|
||||
<>
|
||||
{segments.map((segment) => (
|
||||
<Text key={segment.key} style={getMessageFindHighlightStyle(segment.highlight)}>
|
||||
{segment.text}
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getMessageFindHighlightStyle(highlight: MessageFindHighlight | undefined) {
|
||||
if (highlight?.isCurrent) {
|
||||
return userMessageStylesheet.findCurrentMatchText;
|
||||
}
|
||||
if (highlight) {
|
||||
return userMessageStylesheet.findMatchText;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) {
|
||||
const uri = useAttachmentPreviewUrl(image);
|
||||
const imageSource = useMemo(() => ({ uri: uri ?? "" }), [uri]);
|
||||
@@ -478,6 +608,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
isFirstInGroup = true,
|
||||
isLastInGroup = true,
|
||||
disableOuterSpacing,
|
||||
findHighlights,
|
||||
}: UserMessageProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
@@ -501,6 +632,10 @@ export const UserMessage = memo(function UserMessage({
|
||||
},
|
||||
[rewindMutation],
|
||||
);
|
||||
const highlightedMessageSegments = useMemo(
|
||||
() => createMessageFindTextSegments(message, findHighlights),
|
||||
[findHighlights, message],
|
||||
);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [
|
||||
@@ -570,7 +705,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
) : null}
|
||||
{hasText ? (
|
||||
<Text selectable style={userMessageStylesheet.text}>
|
||||
{message}
|
||||
<MessageFindHighlightedText segments={highlightedMessageSegments} />
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -755,6 +890,7 @@ interface AssistantMessageProps {
|
||||
serverId?: string;
|
||||
client?: DaemonClient | null;
|
||||
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
|
||||
findHighlights?: MessageFindHighlight[];
|
||||
}
|
||||
|
||||
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
@@ -1490,6 +1626,7 @@ interface MemoizedMarkdownBlockProps {
|
||||
rules: RenderRules;
|
||||
parser: MarkdownIt;
|
||||
onLinkPress: (url: string) => boolean;
|
||||
findHighlights?: MessageFindHighlight[];
|
||||
}
|
||||
|
||||
const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
|
||||
@@ -1497,9 +1634,39 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
|
||||
rules,
|
||||
parser,
|
||||
onLinkPress,
|
||||
findHighlights,
|
||||
}: MemoizedMarkdownBlockProps) {
|
||||
const textCursorRef = useRef(0);
|
||||
textCursorRef.current = 0;
|
||||
const renderFindHighlightedText = useCallback(
|
||||
(content: string) => {
|
||||
if (!findHighlights || findHighlights.length === 0 || content.length === 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const start = text.indexOf(content, textCursorRef.current);
|
||||
if (start < 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
textCursorRef.current = start + content.length;
|
||||
const end = start + content.length;
|
||||
const contentHighlights = findHighlights
|
||||
.filter((highlight) => highlight.start < end && highlight.end > start)
|
||||
.map((highlight) => ({
|
||||
id: highlight.id,
|
||||
start: Math.max(0, highlight.start - start),
|
||||
end: Math.min(content.length, highlight.end - start),
|
||||
isCurrent: highlight.isCurrent,
|
||||
}));
|
||||
const segments = createMessageFindTextSegments(content, contentHighlights);
|
||||
return <MessageFindHighlightedText segments={segments} />;
|
||||
},
|
||||
[findHighlights, text],
|
||||
);
|
||||
|
||||
return (
|
||||
<AppearanceStyleBoundary>
|
||||
<MarkdownFindHighlightContext.Provider value={renderFindHighlightedText}>
|
||||
<ThemedMarkdown
|
||||
uniProps={markdownStyleMapping}
|
||||
rules={rules}
|
||||
@@ -1510,45 +1677,37 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
|
||||
>
|
||||
{text}
|
||||
</ThemedMarkdown>
|
||||
</AppearanceStyleBoundary>
|
||||
</MarkdownFindHighlightContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
const MarkdownFindHighlightContext = createContext<((content: string) => ReactNode) | null>(null);
|
||||
|
||||
interface MarkdownInheritedTextProps {
|
||||
inheritedStyles: TextStyle;
|
||||
textStyle: TextStyle;
|
||||
style?: StyleProp<TextStyle>;
|
||||
monoSurface?: boolean;
|
||||
children: ReactNode;
|
||||
disableFindHighlight?: boolean;
|
||||
}
|
||||
|
||||
function MarkdownInheritedText({
|
||||
inheritedStyles,
|
||||
textStyle,
|
||||
style: overrideStyle,
|
||||
monoSurface,
|
||||
children,
|
||||
disableFindHighlight = false,
|
||||
}: MarkdownInheritedTextProps) {
|
||||
const renderFindHighlightedText = useContext(MarkdownFindHighlightContext);
|
||||
const style = useMemo(
|
||||
() => [inheritedStyles, textStyle, overrideStyle],
|
||||
[inheritedStyles, textStyle, overrideStyle],
|
||||
);
|
||||
// When this span renders link label text on iOS, pick up the link's press
|
||||
// handler from context and hand it to MarkdownTextSpan, which forwards it to
|
||||
// the leaf string children react-native-uitextview makes tappable. Null
|
||||
// outside a link (and on every other platform, where no provider mounts), so
|
||||
// ordinary text is unaffected. See assistant-file-links/link-press-context.
|
||||
const linkPress = useAssistantLinkPress();
|
||||
return (
|
||||
<MarkdownTextSpan
|
||||
monoSurface={monoSurface}
|
||||
style={style}
|
||||
onPress={linkPress?.onPress}
|
||||
accessibilityRole={linkPress?.accessibilityRole}
|
||||
>
|
||||
{children}
|
||||
</MarkdownTextSpan>
|
||||
);
|
||||
const renderedChildren =
|
||||
!disableFindHighlight && typeof children === "string" && renderFindHighlightedText
|
||||
? renderFindHighlightedText(children)
|
||||
: children;
|
||||
return <MarkdownTextSpan style={style}>{renderedChildren}</MarkdownTextSpan>;
|
||||
}
|
||||
|
||||
interface MarkdownListItemContentProps {
|
||||
@@ -1581,6 +1740,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
serverId,
|
||||
client,
|
||||
spacing = "default",
|
||||
findHighlights,
|
||||
}: AssistantMessageProps) {
|
||||
const markdownParser = useMemo(() => {
|
||||
const parser = MarkdownIt({ typographer: true, linkify: true });
|
||||
@@ -1635,67 +1795,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
// strong/em/s have no custom rule in react-native-markdown-display's
|
||||
// defaults beyond wrapping children in a plain RN <Text>. On iOS the
|
||||
// paragraph/textgroup are native UITextViews (see markdown-text.ios.tsx),
|
||||
// and a plain <Text> nested inside one is not hoisted into a
|
||||
// UITextViewChild, so its content renders invisibly. Route these inline
|
||||
// marks through MarkdownTextSpan (same path as text/textgroup) so the
|
||||
// styled content composes and stays visible + selectable on iOS.
|
||||
strong: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.strong}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
em: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.em}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
s: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.s}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
// hardbreak/softbreak fall back to react-native-markdown-display's
|
||||
// default, a plain RN <Text>{"\n"}. Inside the paragraph UITextView that
|
||||
// plain <Text> is not hoisted into a UITextViewChild and is dropped (same
|
||||
// root cause as strong/em/s) — so on iOS a hard line break vanished, and
|
||||
// a softbreak between words jammed them together ("one\ntwo" -> "onetwo").
|
||||
// Emit the break through MarkdownTextSpan so it composes on iOS; web and
|
||||
// Android keep the same "\n" they rendered before.
|
||||
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
code_block: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
@@ -1779,7 +1878,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_inline}
|
||||
monoSurface
|
||||
disableFindHighlight
|
||||
>
|
||||
{content}
|
||||
</MarkdownInheritedText>
|
||||
@@ -1838,11 +1937,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownParagraphView
|
||||
key={node.key}
|
||||
paragraphStyle={styles.paragraph}
|
||||
containsImage={markdownNodeContainsType(node, "image")}
|
||||
>
|
||||
<MarkdownParagraphView key={node.key} paragraphStyle={styles.paragraph}>
|
||||
{children}
|
||||
</MarkdownParagraphView>
|
||||
),
|
||||
@@ -1891,11 +1986,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
};
|
||||
}, [client, fileLinkActions, markdownParser, serverId, workspaceRoot]);
|
||||
|
||||
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
|
||||
const keyedBlocks = useMemo(
|
||||
() => blocks.map((block, index) => ({ key: `${index}:${block.slice(0, 32)}`, block })),
|
||||
[blocks],
|
||||
);
|
||||
const keyedBlocks = useMemo(() => createMarkdownBlocksWithOffsets(message), [message]);
|
||||
|
||||
const assistantContainerStyle = useMemo(
|
||||
() => [
|
||||
@@ -1910,7 +2001,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
|
||||
return (
|
||||
<View testID="assistant-message" style={assistantContainerStyle}>
|
||||
{keyedBlocks.map(({ key, block }, index) => (
|
||||
{keyedBlocks.map(({ key, block, startOffset }, index) => (
|
||||
<AssistantMessageBlockContainer
|
||||
key={key}
|
||||
block={block}
|
||||
@@ -1921,6 +2012,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
rules={markdownRules}
|
||||
parser={markdownParser}
|
||||
onLinkPress={handleMarkdownLinkPress}
|
||||
findHighlights={createBlockFindHighlights(startOffset, block.length, findHighlights)}
|
||||
/>
|
||||
</AssistantMessageBlockContainer>
|
||||
))}
|
||||
@@ -1948,13 +2040,13 @@ const speakMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
headerLabel: {
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
fontFamily: Fonts.sans,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
text: {
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
fontFamily: Fonts.sans,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 22,
|
||||
color: theme.colors.foreground,
|
||||
@@ -2064,7 +2156,7 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({
|
||||
metadataText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.code,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontFamily: Fonts.mono,
|
||||
lineHeight: 16,
|
||||
},
|
||||
}));
|
||||
@@ -2160,7 +2252,7 @@ export const ActivityLog = memo(function ActivityLog({
|
||||
</View>
|
||||
</View>
|
||||
{isExpanded && metadata && (
|
||||
<View style={activityLogStylesheet.metadataContainer} dataSet={CODE_SURFACE_DATASET}>
|
||||
<View style={activityLogStylesheet.metadataContainer}>
|
||||
<Text style={activityLogStylesheet.metadataText}>
|
||||
{JSON.stringify(metadata, null, 2)}
|
||||
</Text>
|
||||
@@ -2196,7 +2288,7 @@ const compactionStylesheet = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
text: {
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
fontFamily: Fonts.sans,
|
||||
fontSize: 13,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
|
||||
@@ -16,9 +16,8 @@ import {
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
@@ -53,12 +52,7 @@ function DiscoveredModelRow({ model }: { model: AgentModelDefinition }) {
|
||||
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
|
||||
{model.label}
|
||||
</Text>
|
||||
<Text
|
||||
style={sheetStyles.monoHint}
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
>
|
||||
<Text style={sheetStyles.monoHint} numberOfLines={1} selectable>
|
||||
{model.id}
|
||||
</Text>
|
||||
{model.description ? (
|
||||
@@ -95,12 +89,7 @@ function CustomModelRow({
|
||||
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
|
||||
{model.label}
|
||||
</Text>
|
||||
<Text
|
||||
style={sheetStyles.monoHint}
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
>
|
||||
<Text style={sheetStyles.monoHint} numberOfLines={1} selectable>
|
||||
{model.id}
|
||||
</Text>
|
||||
<View style={sheetStyles.modelRowFiller} />
|
||||
@@ -196,7 +185,6 @@ function AddCustomModelSubSheet({
|
||||
onClose={onClose}
|
||||
desktopMaxWidth={420}
|
||||
snapPoints={ADD_SNAP_POINTS}
|
||||
testID="add-custom-model-sheet"
|
||||
>
|
||||
<View style={sheetStyles.formGroup}>
|
||||
<Text style={sheetStyles.formLabel}>Model ID</Text>
|
||||
@@ -319,7 +307,7 @@ function DiagnosticSubSheet({
|
||||
body = (
|
||||
<ScrollView style={sheetStyles.codeScroll} contentContainerStyle={sheetStyles.codeContent}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator>
|
||||
<Text style={sheetStyles.codeText} selectable dataSet={CODE_SURFACE_DATASET}>
|
||||
<Text style={sheetStyles.codeText} selectable>
|
||||
{diagnostic}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
@@ -340,7 +328,6 @@ function DiagnosticSubSheet({
|
||||
onClose={onClose}
|
||||
snapPoints={DIAGNOSTIC_SNAP_POINTS}
|
||||
scrollable={false}
|
||||
testID="provider-diagnostic-sheet"
|
||||
>
|
||||
<View style={DIAGNOSTIC_CARD_STYLE}>{body}</View>
|
||||
</AdaptiveModalSheet>
|
||||
@@ -362,69 +349,6 @@ interface ProviderModalBodyProps {
|
||||
theme: { iconSize: { md: number }; colors: { foregroundMuted: string } };
|
||||
}
|
||||
|
||||
interface ProviderSheetFooterInput {
|
||||
fetchedAtLabel: string | null;
|
||||
isCompact: boolean;
|
||||
modelsRefreshing: boolean;
|
||||
onOpenAddSheet: () => void;
|
||||
onOpenDiagSheet: () => void;
|
||||
onRefreshModels: () => void;
|
||||
}
|
||||
|
||||
function renderProviderSheetFooter({
|
||||
fetchedAtLabel,
|
||||
isCompact,
|
||||
modelsRefreshing,
|
||||
onOpenAddSheet,
|
||||
onOpenDiagSheet,
|
||||
onRefreshModels,
|
||||
}: ProviderSheetFooterInput) {
|
||||
const contentStyle = isCompact ? sheetStyles.compactFooterContent : sheetStyles.footerContent;
|
||||
const actionsStyle = isCompact ? sheetStyles.compactFooterActions : sheetStyles.footerActions;
|
||||
const buttonStyle = isCompact ? sheetStyles.compactFooterButton : null;
|
||||
const metaStyle = isCompact ? COMPACT_FOOTER_META_STYLE : sheetStyles.footerMeta;
|
||||
|
||||
return (
|
||||
<View style={contentStyle}>
|
||||
{fetchedAtLabel || !isCompact ? (
|
||||
<Text style={metaStyle} numberOfLines={1}>
|
||||
{fetchedAtLabel ? `Updated ${fetchedAtLabel}` : ""}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={actionsStyle}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftIcon={Plus}
|
||||
onPress={onOpenAddSheet}
|
||||
style={buttonStyle}
|
||||
>
|
||||
Add model
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftIcon={FileText}
|
||||
onPress={onOpenDiagSheet}
|
||||
style={buttonStyle}
|
||||
>
|
||||
Diagnostic
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftIcon={modelsRefreshing ? undefined : RotateCw}
|
||||
onPress={onRefreshModels}
|
||||
disabled={modelsRefreshing}
|
||||
style={buttonStyle}
|
||||
>
|
||||
{modelsRefreshing ? "Refreshing…" : "Refresh"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderModalBody(props: ProviderModalBodyProps) {
|
||||
const {
|
||||
discoveredCount,
|
||||
@@ -512,7 +436,6 @@ export function ProviderDiagnosticSheet({
|
||||
serverId,
|
||||
}: ProviderDiagnosticSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId);
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -612,21 +535,38 @@ export function ProviderDiagnosticSheet({
|
||||
[providerLabel],
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Text style={sheetStyles.footerMeta} numberOfLines={1}>
|
||||
{fetchedAtLabel ? `Updated ${fetchedAtLabel}` : ""}
|
||||
</Text>
|
||||
<View style={sheetStyles.footerActions}>
|
||||
<Button variant="secondary" size="sm" leftIcon={Plus} onPress={handleOpenAddSheet}>
|
||||
Add model
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" leftIcon={FileText} onPress={handleOpenDiagSheet}>
|
||||
Diagnostic
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
leftIcon={modelsRefreshing ? undefined : RotateCw}
|
||||
onPress={handleRefreshModels}
|
||||
disabled={modelsRefreshing}
|
||||
>
|
||||
{modelsRefreshing ? "Refreshing…" : "Refresh"}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdaptiveModalSheet
|
||||
header={sheetHeader}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
testID="provider-settings-sheet"
|
||||
footer={renderProviderSheetFooter({
|
||||
fetchedAtLabel,
|
||||
isCompact,
|
||||
modelsRefreshing,
|
||||
onOpenAddSheet: handleOpenAddSheet,
|
||||
onOpenDiagSheet: handleOpenDiagSheet,
|
||||
onRefreshModels: handleRefreshModels,
|
||||
})}
|
||||
footer={footer}
|
||||
snapPoints={MAIN_SNAP_POINTS}
|
||||
>
|
||||
<ProviderModalBody
|
||||
@@ -667,7 +607,7 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
monoHint: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 0,
|
||||
@@ -742,36 +682,16 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
footerContent: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
compactFooterContent: {
|
||||
flex: 1,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
footerMeta: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
compactFooterMeta: {
|
||||
flex: 0,
|
||||
},
|
||||
footerActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
compactFooterActions: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
compactFooterButton: {
|
||||
alignSelf: "stretch",
|
||||
},
|
||||
formGroup: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
@@ -796,7 +716,7 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
codeText: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
@@ -811,7 +731,6 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
const FORM_INPUT_STYLE = [sheetStyles.formInput, isWeb && { outlineStyle: "none" }];
|
||||
const COMPACT_FOOTER_META_STYLE = [sheetStyles.footerMeta, sheetStyles.compactFooterMeta];
|
||||
|
||||
const MAIN_SNAP_POINTS = ["65%", "92%"];
|
||||
const ADD_SNAP_POINTS = ["40%"];
|
||||
|
||||
@@ -5,7 +5,6 @@ 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" });
|
||||
expect(resolveProviderIconName("omp")).toEqual({ kind: "builtin", id: "omp" });
|
||||
});
|
||||
|
||||
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
|
||||
|
||||
export type BuiltinProviderIconName =
|
||||
| "claude"
|
||||
| "codex"
|
||||
| "copilot"
|
||||
| "kiro"
|
||||
| "omp"
|
||||
| "opencode"
|
||||
| "pi";
|
||||
export type BuiltinProviderIconName = "claude" | "codex" | "copilot" | "kiro" | "opencode" | "pi";
|
||||
|
||||
export type ProviderIconName =
|
||||
| { kind: "builtin"; id: BuiltinProviderIconName }
|
||||
@@ -19,7 +12,6 @@ const BUILTIN_PROVIDER_IDS: ReadonlySet<BuiltinProviderIconName> = new Set([
|
||||
"codex",
|
||||
"copilot",
|
||||
"kiro",
|
||||
"omp",
|
||||
"opencode",
|
||||
"pi",
|
||||
]);
|
||||
|
||||
@@ -24,7 +24,6 @@ const BUILTIN_PROVIDER_ICONS: Record<BuiltinProviderIconName, ProviderIconCompon
|
||||
codex: CodexIcon as unknown as ProviderIconComponent,
|
||||
copilot: CopilotIcon as unknown as ProviderIconComponent,
|
||||
kiro: PackagePlus,
|
||||
omp: PiIcon as unknown as ProviderIconComponent,
|
||||
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
|
||||
pi: PiIcon as unknown as ProviderIconComponent,
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
||||
export function ProviderSettingsHost() {
|
||||
const serverId = useProviderSettingsStore((state) => state.serverId);
|
||||
const provider = useProviderSettingsStore((state) => state.provider);
|
||||
const visible = useProviderSettingsStore((state) => state.visible);
|
||||
const close = useProviderSettingsStore((state) => state.close);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
@@ -20,7 +19,7 @@ export function ProviderSettingsHost() {
|
||||
<ProviderDiagnosticSheet
|
||||
provider={provider}
|
||||
serverId={serverId}
|
||||
visible={visible}
|
||||
visible
|
||||
onClose={handleClose}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -76,27 +76,4 @@ describe("question form card core", () => {
|
||||
Response: "custom",
|
||||
});
|
||||
});
|
||||
|
||||
test("shows text input for questions that allow other answers", () => {
|
||||
const questions = parseQuestionFormQuestions({
|
||||
questions: [
|
||||
{
|
||||
question: "Pick or type",
|
||||
header: "Response",
|
||||
options: [{ label: "A" }],
|
||||
allowOther: 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, X } from "lucide-react-native";
|
||||
import { Check, CircleHelp, X } from "lucide-react-native";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -82,18 +82,9 @@ function QuestionOptionRow({
|
||||
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={pressableStyle}
|
||||
onPress={handlePress}
|
||||
disabled={isResponding}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={option.label}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
<Pressable style={pressableStyle} onPress={handlePress} disabled={isResponding}>
|
||||
<View style={styles.optionItemContent}>
|
||||
<View style={styles.optionTextBlock}>
|
||||
<Text style={optionLabelStyle}>{option.label}</Text>
|
||||
@@ -111,73 +102,8 @@ function QuestionOptionRow({
|
||||
);
|
||||
}
|
||||
|
||||
interface QuestionNavButtonProps {
|
||||
index: number;
|
||||
total: number;
|
||||
isActive: boolean;
|
||||
isResponding: boolean;
|
||||
onSelect: (index: number) => void;
|
||||
}
|
||||
|
||||
function QuestionNavButton({
|
||||
index,
|
||||
total,
|
||||
isActive,
|
||||
isResponding,
|
||||
onSelect,
|
||||
}: QuestionNavButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const accessibilityState = useMemo(() => ({ selected: isActive }), [isActive]);
|
||||
const handlePress = useCallback(() => {
|
||||
onSelect(index);
|
||||
}, [index, onSelect]);
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
return [
|
||||
styles.questionNavButton,
|
||||
{
|
||||
backgroundColor:
|
||||
isActive || Boolean(hovered) ? theme.colors.surface2 : theme.colors.surface1,
|
||||
borderColor: isActive ? theme.colors.foregroundMuted : theme.colors.border,
|
||||
},
|
||||
pressed && styles.optionItemPressed,
|
||||
];
|
||||
},
|
||||
[
|
||||
isActive,
|
||||
theme.colors.border,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.colors.surface1,
|
||||
theme.colors.surface2,
|
||||
],
|
||||
);
|
||||
const textStyle = useMemo(
|
||||
() => [
|
||||
styles.questionNavText,
|
||||
{ color: isActive ? theme.colors.foreground : theme.colors.foregroundMuted },
|
||||
],
|
||||
[isActive, theme.colors.foreground, theme.colors.foregroundMuted],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Question ${index + 1} of ${total}`}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-selected={isActive}
|
||||
testID={`question-form-question-nav-${index + 1}`}
|
||||
style={pressableStyle}
|
||||
onPress={handlePress}
|
||||
disabled={isResponding}
|
||||
>
|
||||
<Text style={textStyle}>{index + 1}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
interface QuestionOtherInputProps {
|
||||
qIndex: number;
|
||||
accessibilityLabel: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
isResponding: boolean;
|
||||
@@ -187,7 +113,6 @@ interface QuestionOtherInputProps {
|
||||
|
||||
function QuestionOtherInput({
|
||||
qIndex,
|
||||
accessibilityLabel,
|
||||
value,
|
||||
placeholder,
|
||||
isResponding,
|
||||
@@ -224,7 +149,6 @@ function QuestionOtherInput({
|
||||
<TextInput
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={otherInputStyle}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
value={value}
|
||||
@@ -239,19 +163,15 @@ function QuestionOtherInput({
|
||||
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const questions = useMemo(
|
||||
() => parseQuestionFormQuestions(permission.request.input),
|
||||
[permission.request.input],
|
||||
);
|
||||
const questions = parseQuestionFormQuestions(permission.request.input);
|
||||
|
||||
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
|
||||
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
|
||||
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
|
||||
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
|
||||
|
||||
const toggleOption = useCallback(
|
||||
(qIndex: number, optIndex: number, multiSelect: boolean) => {
|
||||
const current = selections[qIndex] ?? new Set<number>();
|
||||
const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => {
|
||||
setSelections((prev) => {
|
||||
const current = prev[qIndex] ?? new Set<number>();
|
||||
const next = new Set(current);
|
||||
if (multiSelect) {
|
||||
if (next.has(optIndex)) {
|
||||
@@ -259,27 +179,23 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
} else {
|
||||
next.add(optIndex);
|
||||
}
|
||||
} else if (next.has(optIndex)) {
|
||||
next.clear();
|
||||
} else {
|
||||
next.clear();
|
||||
next.add(optIndex);
|
||||
if (next.has(optIndex)) {
|
||||
next.clear();
|
||||
} else {
|
||||
next.clear();
|
||||
next.add(optIndex);
|
||||
}
|
||||
}
|
||||
|
||||
setSelections((prev) => ({ ...prev, [qIndex]: next }));
|
||||
setOtherTexts((prev) => {
|
||||
if (!prev[qIndex]) return prev;
|
||||
const nextTexts = { ...prev };
|
||||
delete nextTexts[qIndex];
|
||||
return nextTexts;
|
||||
});
|
||||
|
||||
if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) {
|
||||
setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1));
|
||||
}
|
||||
},
|
||||
[activeQuestionIndex, questions, selections],
|
||||
);
|
||||
return { ...prev, [qIndex]: next };
|
||||
});
|
||||
setOtherTexts((prev) => {
|
||||
if (!prev[qIndex]) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[qIndex];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setOtherText = useCallback((qIndex: number, text: string) => {
|
||||
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
|
||||
@@ -292,10 +208,6 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
}, []);
|
||||
|
||||
const allAnswered = areQuestionsAnswered(questions, selections, otherTexts);
|
||||
const resolvedActiveQuestionIndex = questions
|
||||
? Math.min(activeQuestionIndex, questions.length - 1)
|
||||
: 0;
|
||||
const activeQuestion = questions?.[resolvedActiveQuestionIndex];
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!questions || !allAnswered || isResponding) return;
|
||||
@@ -336,10 +248,6 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
});
|
||||
}, [questions, onRespond, otherTexts, permission.request.input, selections]);
|
||||
|
||||
const handleSelectQuestion = useCallback((index: number) => {
|
||||
setActiveQuestionIndex(index);
|
||||
}, []);
|
||||
|
||||
const dismissButtonStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.actionButton,
|
||||
@@ -354,16 +262,22 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
|
||||
const submitDisabled = !allAnswered || isResponding;
|
||||
const submitButtonStyle = useCallback(
|
||||
({ pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.actionButton,
|
||||
{
|
||||
backgroundColor: theme.colors.accent,
|
||||
borderColor: theme.colors.accent,
|
||||
backgroundColor: hovered && !submitDisabled ? theme.colors.surface2 : theme.colors.surface1,
|
||||
borderColor: submitDisabled ? theme.colors.border : theme.colors.borderAccent,
|
||||
opacity: submitDisabled ? 0.5 : 1,
|
||||
},
|
||||
pressed && !submitDisabled ? styles.optionItemPressed : null,
|
||||
],
|
||||
[submitDisabled, theme.colors.accent],
|
||||
[
|
||||
submitDisabled,
|
||||
theme.colors.surface2,
|
||||
theme.colors.surface1,
|
||||
theme.colors.border,
|
||||
theme.colors.borderAccent,
|
||||
],
|
||||
);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
@@ -380,10 +294,6 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
() => [styles.questionText, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
);
|
||||
const questionNavStyle = useMemo(
|
||||
() => [styles.questionNav, isMobile && styles.questionNavMobile],
|
||||
[isMobile],
|
||||
);
|
||||
const actionsContainerStyle = useMemo(
|
||||
() => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop],
|
||||
[isMobile],
|
||||
@@ -392,7 +302,9 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
() => [styles.actionText, { color: theme.colors.foregroundMuted }],
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
const submitActionTextColor = theme.colors.accentForeground;
|
||||
const submitActionTextColor = allAnswered
|
||||
? theme.colors.foreground
|
||||
: theme.colors.foregroundMuted;
|
||||
const submitActionTextStyle = useMemo(
|
||||
() => [styles.actionText, { color: submitActionTextColor }],
|
||||
[submitActionTextColor],
|
||||
@@ -403,76 +315,52 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
}
|
||||
|
||||
const dismissLabel = resolveDismissLabel(questions);
|
||||
const selected = selections[resolvedActiveQuestionIndex] ?? new Set<number>();
|
||||
const otherText = otherTexts[resolvedActiveQuestionIndex] ?? "";
|
||||
const showTextInput = activeQuestion ? questionShowsTextInput(activeQuestion) : false;
|
||||
|
||||
return (
|
||||
<View style={containerStyle} testID="question-form-card">
|
||||
<View style={styles.questionTopRow}>
|
||||
<View style={styles.questionHeader}>
|
||||
<Text testID="question-form-current-question" style={questionTextStyle}>
|
||||
{activeQuestion?.question}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={questionNavStyle} testID="question-form-question-nav">
|
||||
{questions.map((question, qIndex) => {
|
||||
const isActive = qIndex === resolvedActiveQuestionIndex;
|
||||
return (
|
||||
<QuestionNavButton
|
||||
key={question.header}
|
||||
index={qIndex}
|
||||
total={questions.length}
|
||||
isActive={isActive}
|
||||
isResponding={isResponding}
|
||||
onSelect={handleSelectQuestion}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
<View style={containerStyle}>
|
||||
{questions.map((q, qIndex) => {
|
||||
const selected = selections[qIndex] ?? new Set<number>();
|
||||
const otherText = otherTexts[qIndex] ?? "";
|
||||
const showTextInput = questionShowsTextInput(q);
|
||||
|
||||
{activeQuestion ? (
|
||||
<View key={activeQuestion.question} style={styles.questionBlock}>
|
||||
{activeQuestion.options.length > 0 ? (
|
||||
<View style={styles.optionsWrap}>
|
||||
{activeQuestion.options.map((opt, optIndex) => (
|
||||
<QuestionOptionRow
|
||||
key={opt.label}
|
||||
qIndex={resolvedActiveQuestionIndex}
|
||||
optIndex={optIndex}
|
||||
option={opt}
|
||||
isSelected={selected.has(optIndex)}
|
||||
multiSelect={activeQuestion.multiSelect}
|
||||
isResponding={isResponding}
|
||||
onToggle={toggleOption}
|
||||
/>
|
||||
))}
|
||||
return (
|
||||
<View key={q.question} style={styles.questionBlock}>
|
||||
<View style={styles.questionHeader}>
|
||||
<Text style={questionTextStyle}>{q.question}</Text>
|
||||
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : null}
|
||||
{showTextInput ? (
|
||||
<QuestionOtherInput
|
||||
qIndex={resolvedActiveQuestionIndex}
|
||||
accessibilityLabel={activeQuestion.question}
|
||||
value={otherText}
|
||||
placeholder={getQuestionInputPlaceholder(activeQuestion)}
|
||||
isResponding={isResponding}
|
||||
onChange={setOtherText}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
|
||||
<View style={actionsContainerStyle}>
|
||||
<Pressable
|
||||
style={dismissButtonStyle}
|
||||
onPress={handleDeny}
|
||||
disabled={isResponding}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={dismissLabel}
|
||||
testID="question-form-dismiss"
|
||||
>
|
||||
<Pressable style={dismissButtonStyle} onPress={handleDeny} disabled={isResponding}>
|
||||
{respondingAction === "dismiss" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
@@ -483,16 +371,9 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={submitButtonStyle}
|
||||
onPress={handleSubmit}
|
||||
disabled={submitDisabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Submit"
|
||||
testID="question-form-primary-action"
|
||||
>
|
||||
<Pressable style={submitButtonStyle} onPress={handleSubmit} disabled={submitDisabled}>
|
||||
{respondingAction === "submit" ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.accentForeground} />
|
||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||
) : (
|
||||
<View style={styles.actionContent}>
|
||||
<Check size={14} color={submitActionTextColor} />
|
||||
@@ -515,50 +396,21 @@ const styles = StyleSheet.create((theme) => ({
|
||||
questionBlock: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
questionTopRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
questionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[1],
|
||||
flex: 1,
|
||||
},
|
||||
questionText: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
lineHeight: 22,
|
||||
},
|
||||
optionsWrap: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
questionNav: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
questionNavMobile: {
|
||||
paddingRight: theme.spacing[1],
|
||||
},
|
||||
questionNavButton: {
|
||||
minWidth: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 999,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
},
|
||||
questionNavText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: "700",
|
||||
},
|
||||
optionItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeResizeHandleSizes } from "@/components/resize-handle-sizes";
|
||||
|
||||
describe("computeResizeHandleSizes", () => {
|
||||
it("clamps right-edge drags to the adjacent pane minimum", () => {
|
||||
const sizes = computeResizeHandleSizes({
|
||||
sizes: [0.25, 0.5, 0.25],
|
||||
index: 1,
|
||||
deltaRatio: 0.5,
|
||||
});
|
||||
|
||||
expect(sizes[0]).toBe(0.25);
|
||||
expect(sizes[1]).toBe(0.65);
|
||||
expect(sizes[2]).toBeCloseTo(0.1, 10);
|
||||
});
|
||||
|
||||
it("clamps left-edge drags to the adjacent pane minimum", () => {
|
||||
const sizes = computeResizeHandleSizes({
|
||||
sizes: [0.25, 0.5, 0.25],
|
||||
index: 1,
|
||||
deltaRatio: -0.5,
|
||||
});
|
||||
|
||||
expect(sizes[0]).toBe(0.25);
|
||||
expect(sizes[1]).toBe(0.1);
|
||||
expect(sizes[2]).toBeCloseTo(0.65, 10);
|
||||
});
|
||||
|
||||
it("moves adjacent pane sizes without clamping", () => {
|
||||
const sizes = computeResizeHandleSizes({
|
||||
sizes: [0.25, 0.5, 0.25],
|
||||
index: 1,
|
||||
deltaRatio: 0.05,
|
||||
});
|
||||
|
||||
expect(sizes[0]).toBe(0.25);
|
||||
expect(sizes[1]).toBe(0.55);
|
||||
expect(sizes[2]).toBeCloseTo(0.2, 10);
|
||||
});
|
||||
|
||||
it("splits tiny adjacent pairs evenly when the configured minimum cannot fit", () => {
|
||||
expect(
|
||||
computeResizeHandleSizes({
|
||||
sizes: [0.45, 0.05, 0.05, 0.45],
|
||||
index: 1,
|
||||
deltaRatio: 0.05,
|
||||
}),
|
||||
).toEqual([0.45, 0.05, 0.05, 0.45]);
|
||||
});
|
||||
|
||||
it("leaves sizes unchanged when the adjacent pair is invalid", () => {
|
||||
expect(
|
||||
computeResizeHandleSizes({
|
||||
sizes: [0.25, 0.5, 0.25],
|
||||
index: 3,
|
||||
deltaRatio: 0.25,
|
||||
}),
|
||||
).toEqual([0.25, 0.5, 0.25]);
|
||||
expect(
|
||||
computeResizeHandleSizes({
|
||||
sizes: [0.25, 0, 0, 0.75],
|
||||
index: 1,
|
||||
deltaRatio: 0.25,
|
||||
}),
|
||||
).toEqual([0.25, 0, 0, 0.75]);
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { MIN_SPLIT_SIZE } from "@/stores/workspace-layout-constants";
|
||||
|
||||
interface ComputeResizeHandleSizesInput {
|
||||
sizes: number[];
|
||||
index: number;
|
||||
deltaRatio: number;
|
||||
minSize?: number;
|
||||
}
|
||||
|
||||
export function computeResizeHandleSizes({
|
||||
sizes,
|
||||
index,
|
||||
deltaRatio,
|
||||
minSize = MIN_SPLIT_SIZE,
|
||||
}: ComputeResizeHandleSizesInput): number[] {
|
||||
const nextSizes = sizes.slice();
|
||||
const leftSize = sizes[index];
|
||||
const rightSize = sizes[index + 1];
|
||||
if (leftSize === undefined || rightSize === undefined) {
|
||||
return nextSizes;
|
||||
}
|
||||
|
||||
const pairSize = leftSize + rightSize;
|
||||
if (pairSize <= 0) {
|
||||
return nextSizes;
|
||||
}
|
||||
|
||||
const adjacentMinSize = Math.min(minSize, pairSize / 2);
|
||||
const nextLeftSize = Math.min(
|
||||
pairSize - adjacentMinSize,
|
||||
Math.max(adjacentMinSize, leftSize + deltaRatio),
|
||||
);
|
||||
nextSizes[index] = nextLeftSize;
|
||||
nextSizes[index + 1] = pairSize - nextLeftSize;
|
||||
return nextSizes;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { View, type PointerEvent as RNPointerEvent } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { computeResizeHandleSizes } from "@/components/resize-handle-sizes";
|
||||
|
||||
export interface ResizeHandleProps {
|
||||
direction: "horizontal" | "vertical";
|
||||
@@ -14,14 +13,8 @@ export interface ResizeHandleProps {
|
||||
interface PointerState {
|
||||
containerSize: number;
|
||||
pointerStart: number;
|
||||
}
|
||||
|
||||
function resetWindowHorizontalScroll() {
|
||||
// Clamp any browser scroll introduced while dragging past the viewport edge.
|
||||
if (window.scrollX === 0) {
|
||||
return;
|
||||
}
|
||||
window.scrollTo(0, window.scrollY);
|
||||
leftSize: number;
|
||||
rightSize: number;
|
||||
}
|
||||
|
||||
export function ResizeHandle({
|
||||
@@ -32,8 +25,7 @@ export function ResizeHandle({
|
||||
onResizeSplit,
|
||||
}: ResizeHandleProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const pointerStatesRef = useRef(new Map<number, PointerState>());
|
||||
const cursorBeforeDragRef = useRef<string | null>(null);
|
||||
const pointerStateRef = useRef<PointerState | null>(null);
|
||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [active, setActive] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
@@ -42,11 +34,7 @@ export function ResizeHandle({
|
||||
const handlePointerDown = useCallback(
|
||||
(event: RNPointerEvent) => {
|
||||
const hitAreaElement = event.currentTarget as unknown as HTMLElement | null;
|
||||
if (!hitAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerElement = hitAreaElement.parentElement?.parentElement ?? null;
|
||||
const containerElement = hitAreaElement?.parentElement?.parentElement ?? null;
|
||||
if (!containerElement) {
|
||||
return;
|
||||
}
|
||||
@@ -57,83 +45,51 @@ export function ResizeHandle({
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerId = event.nativeEvent.pointerId;
|
||||
if (pointerStatesRef.current.has(pointerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDragging(true);
|
||||
|
||||
pointerStatesRef.current.set(pointerId, {
|
||||
pointerStateRef.current = {
|
||||
containerSize,
|
||||
pointerStart:
|
||||
direction === "horizontal" ? event.nativeEvent.clientX : event.nativeEvent.clientY,
|
||||
});
|
||||
leftSize: sizes[index] ?? 0,
|
||||
rightSize: sizes[index + 1] ?? 0,
|
||||
};
|
||||
|
||||
if (pointerStatesRef.current.size === 1) {
|
||||
cursorBeforeDragRef.current = document.body.style.cursor;
|
||||
}
|
||||
const previousCursor = document.body.style.cursor;
|
||||
const nextCursor = direction === "horizontal" ? "col-resize" : "row-resize";
|
||||
document.body.style.cursor = nextCursor;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const pointerCaptureElement = hitAreaElement;
|
||||
pointerCaptureElement.setPointerCapture?.(pointerId);
|
||||
resetWindowHorizontalScroll();
|
||||
|
||||
function cleanup() {
|
||||
pointerStatesRef.current.delete(pointerId);
|
||||
setDragging(pointerStatesRef.current.size > 0);
|
||||
if (pointerStatesRef.current.size === 0) {
|
||||
document.body.style.cursor = cursorBeforeDragRef.current ?? "";
|
||||
cursorBeforeDragRef.current = null;
|
||||
}
|
||||
if (pointerCaptureElement.hasPointerCapture?.(pointerId)) {
|
||||
pointerCaptureElement.releasePointerCapture(pointerId);
|
||||
}
|
||||
resetWindowHorizontalScroll();
|
||||
pointerStateRef.current = null;
|
||||
setDragging(false);
|
||||
document.body.style.cursor = previousCursor;
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
window.removeEventListener("pointercancel", handlePointerUp);
|
||||
}
|
||||
|
||||
function handlePointerMove(moveEvent: PointerEvent) {
|
||||
if (moveEvent.pointerId !== pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerState = pointerStatesRef.current.get(pointerId);
|
||||
const pointerState = pointerStateRef.current;
|
||||
if (!pointerState) {
|
||||
return;
|
||||
}
|
||||
|
||||
moveEvent.preventDefault();
|
||||
resetWindowHorizontalScroll();
|
||||
const pointerCurrent = direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
|
||||
const deltaRatio =
|
||||
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
|
||||
|
||||
onResizeSplit(
|
||||
groupId,
|
||||
computeResizeHandleSizes({
|
||||
sizes,
|
||||
index,
|
||||
deltaRatio,
|
||||
}),
|
||||
);
|
||||
const nextSizes = sizes.slice();
|
||||
nextSizes[index] = pointerState.leftSize + deltaRatio;
|
||||
nextSizes[index + 1] = pointerState.rightSize - deltaRatio;
|
||||
onResizeSplit(groupId, nextSizes);
|
||||
}
|
||||
|
||||
function handlePointerUp(upEvent: PointerEvent) {
|
||||
if (upEvent.pointerId !== pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
window.addEventListener("pointercancel", handlePointerUp);
|
||||
window.addEventListener("pointerup", handlePointerUp, { once: true });
|
||||
},
|
||||
[direction, groupId, index, onResizeSplit, sizes],
|
||||
);
|
||||
@@ -174,7 +130,6 @@ export function ResizeHandle({
|
||||
direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical,
|
||||
{
|
||||
cursor: direction === "horizontal" ? "col-resize" : "row-resize",
|
||||
touchAction: "none",
|
||||
} as object,
|
||||
],
|
||||
[direction],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user