Compare commits

..
Author SHA1 Message Date
Mohamed Boudra bcb3157e75 test(app): isolate terminal pane settings mock 2026-05-31 12:45:29 +07:00
Mohamed Boudra de162b98d4 test(app): fix pane test mocks 2026-05-31 12:45:29 +07:00
Mohamed Boudra 0a960f9ae0 fix(app): reconcile find-in-pane with main after rebase
Resolve drift where main evolved the same areas this feature touches:

- view.tsx: keep main's agent-stream/ layout refactor, drop the
  working-indicator/turn-copy helpers main relocated, fix moved import paths
- terminal-emulator.tsx: expose terminal find on the typed ref handle only
  (main's DOMImperativeFactory is JSON-serializable-only)
- file-pane.tsx: render syntax highlighting and find highlighting together,
  pass lineSelection through the searchable preview
- tests/e2e: track main's PaneContextValue.openImportSheet,
  TerminalEmulatorHandle (restoreOutput/blur), and the seed-client helper
2026-05-31 12:45:29 +07:00
Mohamed Boudra 7c4f8580e9 test(app): broaden terminal find count assertion in find-in-pane spec
The terminal find pane matches the echoed printf command line in addition
to the printed output, so the actual count is 4 / 4. Match any non-zero
count, mirroring the chat assertion below it — this step only verifies
that find found something before clicking away.
2026-05-31 12:45:29 +07:00
Mohamed Boudra 33674e0b10 refactor(app): clean up find-in-pane slop
- A bridge: remove renderer webview find fallback and require the Electron bridge path

- B core: tighten pane-find assertions and hide internal search/render types

- C adapters: align chat/file search registration with highlightable content

- D harness: delete flaky Electron QA harness and simplify keyboard dispatcher scope
2026-05-31 12:45:29 +07:00
Mohamed Boudra 0ecc51bd99 feat(app): add find in pane across workspace panes 2026-05-31 12:45:29 +07:00
Mohamed Boudra 526e6070f8 feat(app): route Electron browser find through main-process owner WebContents 2026-05-31 12:45:29 +07:00
Mohamed Boudra 40d6a9127a Add Electron find QA harness 2026-05-31 12:45:29 +07:00
295 changed files with 8462 additions and 11782 deletions
-52
View File
@@ -1,57 +1,5 @@
# Changelog
## 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
-1
View File
@@ -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 |
+17 -11
View File
@@ -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
@@ -77,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.
@@ -93,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.
-2
View File
@@ -183,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
+1 -4
View File
@@ -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 }
@@ -209,7 +206,7 @@ One file per schedule. ID is 8 hex characters. Writes are direct (not atomic).
### 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`)
+1 -73
View File
@@ -43,70 +43,6 @@ In any worktree-style or portless setup, never assume default ports.
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
Override the 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
```
### Desktop macOS compositor watchdog
macOS display sleep can leave Chromium's GPU-process display link — the vsync
@@ -123,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
@@ -158,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:
+1 -2
View File
@@ -18,8 +18,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **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.
+24 -44
View File
@@ -191,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
@@ -201,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
@@ -320,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
@@ -400,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
@@ -433,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
-92
View File
@@ -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 |
-15
View File
@@ -289,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
View File
@@ -1 +1 @@
sha256-R2EUXh8wV0bjdeDNWMhFzd3iDV/dpkLUikbX7nzoMXQ=
sha256-ngMXsN0UDo4ldTMQgDQ4r6XfAvwnwh4hzglIcmGYu9E=
+21 -91
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.89",
"version": "0.1.87",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.89",
"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.89",
"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.89",
"version": "0.1.87",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.89",
"@getpaseo/protocol": "0.1.89",
"@getpaseo/server": "0.1.89",
"@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.89",
"version": "0.1.87",
"dependencies": {
"@getpaseo/protocol": "0.1.89",
"@getpaseo/relay": "0.1.89",
"@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.89",
"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.89",
"version": "0.1.87",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -37740,10 +37672,8 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.89",
"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.89",
"version": "0.1.87",
"dependencies": {
"zod": "^3.23.8"
},
@@ -37992,7 +37922,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.89",
"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.89",
"version": "0.1.87",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/client": "0.1.89",
"@getpaseo/highlight": "0.1.89",
"@getpaseo/protocol": "0.1.89",
"@getpaseo/relay": "0.1.89",
"@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.89",
"version": "0.1.87",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.89",
"version": "0.1.87",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [
+193
View File
@@ -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 -6
View File
@@ -5,7 +5,6 @@ import { getServerId } from "./server-id";
const SECTION_LABELS = {
general: "General",
appearance: "Appearance",
shortcuts: "Shortcuts",
integrations: "Integrations",
permissions: "Permissions",
@@ -181,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> {
@@ -25,7 +25,6 @@ import {
expectDiagnosticsContent,
expectAboutContent,
expectGeneralContent,
expectAppearanceContent,
} from "./helpers/settings";
test.describe("Settings sidebar navigation", () => {
@@ -44,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 }) => {
@@ -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);
});
});
@@ -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();
}
});
+18 -4
View File
@@ -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,
+2 -3
View File
@@ -1,10 +1,10 @@
{
"name": "@getpaseo/app",
"version": "0.1.89",
"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"
},
+3 -11
View File
@@ -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}
+33 -5
View File
@@ -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 {
+240 -78
View File
@@ -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"
-22
View File
@@ -73,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";
@@ -626,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} />
+5 -25
View File
@@ -9,11 +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 { 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";
@@ -21,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,21 +50,11 @@ export function AssistantMarkdownLink({
);
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;
// onPress is forwarded (reliable tap-to-open on iOS is tracked by #21).
return (
<FileLinkHoverTooltip filePath={tooltipPath}>
<MarkdownTextSpan
accessibilityRole="link"
monoSurface={monoSurface}
onPress={onPress}
style={style}
>
<Text accessibilityRole="link" onPress={onPress} style={style}>
{children}
</MarkdownTextSpan>
</Text>
</FileLinkHoverTooltip>
);
}
@@ -90,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>
);
@@ -120,7 +100,7 @@ export function AssistantMarkdownCodeLink({
[inheritedStyles, codeInlineStyle, linkStyle],
);
return (
<AssistantMarkdownLink source={source} style={style} monoSurface>
<AssistantMarkdownLink source={source} style={style}>
{children}
</AssistantMarkdownLink>
);
@@ -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
@@ -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],
},
+3 -3
View File
@@ -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
@@ -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,
@@ -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);
});
});
+581 -154
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
import Markdown, { MarkdownIt } from "react-native-markdown-display";
@@ -11,14 +11,14 @@ import {
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
import type { HighlightToken } from "@getpaseo/highlight";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
import { isWeb } from "@/constants/platform";
import { createMarkdownStyles } from "@/styles/markdown-styles";
@@ -26,15 +26,33 @@ import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes } from "@/attachments/service";
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
import {
createFilePaneFindTokenSegments,
createFilePaneLineFindHighlightMap,
createFilePaneTextRenderData,
findFilePaneTextMatches,
type FilePaneFindLineHighlight,
type FilePaneFindMatch,
type FilePaneFindTokenSegment,
} from "@/components/file-pane-text-render-data";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
import type { WorkspaceFileLocation } from "@/workspace/file-open";
import {
FindBar,
type PaneFindMatchState,
type UsePaneFindResult,
usePaneFind,
} from "@/panels/pane-find";
interface CodeLineProps {
tokens: HighlightToken[];
segments: FilePaneFindTokenSegment[];
lineNumber: number;
gutterWidth: number;
highlighted: boolean;
matchBackgroundColor: string;
currentMatchBackgroundColor: string;
onLineRef?: (lineNumber: number, node: View | null) => void;
}
interface FilePreviewBodyProps {
@@ -46,6 +64,50 @@ interface FilePreviewBodyProps {
imagePreviewUri: string | null;
}
interface FilePaneTextScrollRefs {
lineRefs: React.MutableRefObject<Map<number, View>>;
previewScrollRef: React.RefObject<RNScrollView | null>;
registerLineRef: (lineNumber: number, node: View | null) => void;
}
interface FilePaneCenterStateProps {
children: React.ReactNode;
}
interface FilePaneTextPreviewProps {
currentMatchBackgroundColor: string;
findHighlightsByLine: Map<number, FilePaneFindLineHighlight[]>;
gutterWidth: number;
isMarkdownFile: boolean;
isMobile: boolean;
lineSelection: FileLineSelection | null;
markdownParser: ReturnType<typeof MarkdownIt>;
markdownStyles: ReturnType<typeof createMarkdownStyles>;
matchBackgroundColor: string;
preview: ExplorerFile;
previewScrollRef: React.RefObject<RNScrollView | null>;
scrollbar: ReturnType<typeof useWebScrollViewScrollbar>;
showDesktopWebScrollbar: boolean;
textRenderData: ReturnType<typeof createFilePaneTextRenderData> | null;
textScrollRefs: FilePaneTextScrollRefs;
webScrollbarStyle: object;
}
interface FilePaneSearchableTextPreviewProps extends Omit<
FilePaneTextPreviewProps,
"findHighlightsByLine" | "textRenderData"
> {
textRenderData: ReturnType<typeof createFilePaneTextRenderData>;
}
interface FilePaneImagePreviewProps {
imagePreviewUri: string | null;
imageSource: { uri: string } | null;
previewScrollRef: React.RefObject<RNScrollView | null>;
scrollbar: ReturnType<typeof useWebScrollViewScrollbar>;
showDesktopWebScrollbar: boolean;
}
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
@@ -117,11 +179,20 @@ function clampLineSelection(input: {
}
const CodeLine = React.memo(function CodeLine({
tokens,
segments,
lineNumber,
gutterWidth,
highlighted,
matchBackgroundColor,
currentMatchBackgroundColor,
onLineRef,
}: CodeLineProps) {
const setLineRef = useCallback(
(node: View | null) => {
onLineRef?.(lineNumber, node);
},
[lineNumber, onLineRef],
);
const gutterStyle = useMemo(
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
[gutterWidth],
@@ -131,19 +202,28 @@ const CodeLine = React.memo(function CodeLine({
[highlighted],
);
const keyedTokens = useMemo(
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
[tokens],
() => segments.map((segment, index) => ({ key: `${index}-${segment.text}`, segment })),
[segments],
);
return (
<View style={lineStyle}>
<View ref={setLineRef} style={lineStyle}>
<View style={gutterStyle}>
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
{String(lineNumber)}
</Text>
</View>
<Text selectable style={codeLineStyles.lineText}>
{keyedTokens.map(({ key, token }) => (
<CodeLineToken key={key} token={token} />
{keyedTokens.map(({ key, segment }) => (
<CodeLineToken
key={key}
backgroundColor={getFindSegmentBackgroundColor({
currentMatchBackgroundColor,
matchBackgroundColor,
segment,
})}
style={segment.style}
text={segment.text}
/>
))}
</Text>
</View>
@@ -151,11 +231,237 @@ const CodeLine = React.memo(function CodeLine({
});
interface CodeLineTokenProps {
token: HighlightToken;
backgroundColor?: string;
style: HighlightToken["style"];
text: string;
}
function CodeLineToken({ token }: CodeLineTokenProps) {
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
function CodeLineToken({ backgroundColor, style, text }: CodeLineTokenProps) {
const tokenStyle = useMemo(
() => [
style ? syntaxTokenStyleFor(style) : undefined,
backgroundColor ? { backgroundColor } : null,
],
[backgroundColor, style],
);
return <Text style={tokenStyle}>{text}</Text>;
}
function getFindSegmentBackgroundColor(input: {
segment: FilePaneFindTokenSegment;
matchBackgroundColor: string;
currentMatchBackgroundColor: string;
}) {
if (input.segment.isCurrentFindMatch) {
return input.currentMatchBackgroundColor;
}
if (input.segment.isFindMatch) {
return input.matchBackgroundColor;
}
return undefined;
}
function useFilePaneTextScrollRefs(lineNumbers: number[] | null): FilePaneTextScrollRefs {
const previewScrollRef = useRef<RNScrollView>(null);
const lineRefs = useRef(new Map<number, View>());
const registerLineRef = useCallback((lineNumber: number, node: View | null) => {
if (node) {
lineRefs.current.set(lineNumber, node);
return;
}
lineRefs.current.delete(lineNumber);
}, []);
useEffect(() => {
if (!lineNumbers) {
lineRefs.current.clear();
return;
}
const visibleLineNumbers = new Set(lineNumbers);
for (const lineNumber of lineRefs.current.keys()) {
if (!visibleLineNumbers.has(lineNumber)) {
lineRefs.current.delete(lineNumber);
}
}
}, [lineNumbers]);
return {
lineRefs,
previewScrollRef,
registerLineRef,
};
}
function createFilePaneMatchState(
query: string,
matches: FilePaneFindMatch[],
currentMatchIndex: number,
): PaneFindMatchState {
if (query.length === 0) {
return { status: "empty" };
}
if (matches.length === 0) {
return { status: "no-match" };
}
return {
status: "matched",
current: Math.max(0, currentMatchIndex) + 1,
total: matches.length,
};
}
function scrollFilePaneLineIntoView(input: {
lineRefs: React.MutableRefObject<Map<number, View>>;
previewScrollRef: React.RefObject<RNScrollView | null>;
lineNumber: number;
}) {
const lineNode = input.lineRefs.current.get(input.lineNumber);
const scrollNode = input.previewScrollRef.current;
if (!lineNode || !scrollNode) {
return;
}
if (isWeb && "scrollIntoView" in lineNode) {
(
lineNode as unknown as { scrollIntoView(options?: ScrollIntoViewOptions): void }
).scrollIntoView({
block: "center",
inline: "nearest",
});
return;
}
const measurableLineNode = lineNode as View & {
measureLayout?: (
relativeToNativeNode: unknown,
onSuccess: (x: number, y: number) => void,
onFail?: () => void,
) => void;
};
measurableLineNode.measureLayout?.(scrollNode, (_x, y) => {
scrollNode.scrollTo({ y: Math.max(0, y - 48), animated: true });
});
}
function scrollFilePaneLineIntoViewSoon(input: {
lineRefs: React.MutableRefObject<Map<number, View>>;
previewScrollRef: React.RefObject<RNScrollView | null>;
lineNumber: number;
}) {
const schedule =
globalThis.requestAnimationFrame ??
((callback: FrameRequestCallback) => {
setTimeout(() => callback(Date.now()), 0);
return 0;
});
schedule(() => {
scrollFilePaneLineIntoView(input);
});
}
interface FilePaneFindState {
query: string;
matches: FilePaneFindMatch[];
currentMatchIndex: number;
}
const EMPTY_FILE_PANE_FIND_STATE: FilePaneFindState = {
query: "",
matches: [],
currentMatchIndex: 0,
};
function useFilePaneFindAdapter(input: {
textRenderData: ReturnType<typeof createFilePaneTextRenderData> | null;
textScrollRefs: FilePaneTextScrollRefs;
}) {
const [findState, setFindState] = useState<FilePaneFindState>(EMPTY_FILE_PANE_FIND_STATE);
const findQuery = findState.query;
const findMatches = findState.matches;
const currentMatchIndex = findState.currentMatchIndex;
const findHighlightsByLine = useMemo(
() => createFilePaneLineFindHighlightMap(findMatches, currentMatchIndex),
[currentMatchIndex, findMatches],
);
const findMatchState = useMemo(
() => createFilePaneMatchState(findQuery, findMatches, currentMatchIndex),
[currentMatchIndex, findMatches, findQuery],
);
const scrollMatchIntoView = useCallback(
(matches: FilePaneFindMatch[], matchIndex: number) => {
const lineNumber = matches[matchIndex]?.lineSpans[0]?.lineNumber;
if (!lineNumber) {
return;
}
scrollFilePaneLineIntoViewSoon({
lineRefs: input.textScrollRefs.lineRefs,
lineNumber,
previewScrollRef: input.textScrollRefs.previewScrollRef,
});
},
[input.textScrollRefs.lineRefs, input.textScrollRefs.previewScrollRef],
);
const paneFind = usePaneFind({
matchState: findMatchState,
onQuery: (query) => {
const nextMatches = input.textRenderData
? findFilePaneTextMatches(input.textRenderData, query)
: [];
setFindState({ query, matches: nextMatches, currentMatchIndex: 0 });
scrollMatchIntoView(nextMatches, 0);
return createFilePaneMatchState(query, nextMatches, 0);
},
onNext: () => {
if (findMatches.length === 0) {
return createFilePaneMatchState(findQuery, findMatches, currentMatchIndex);
}
const nextIndex = (currentMatchIndex + 1) % findMatches.length;
setFindState((current) => ({ ...current, currentMatchIndex: nextIndex }));
scrollMatchIntoView(findMatches, nextIndex);
return createFilePaneMatchState(findQuery, findMatches, nextIndex);
},
onPrev: () => {
if (findMatches.length === 0) {
return createFilePaneMatchState(findQuery, findMatches, currentMatchIndex);
}
const nextIndex = (currentMatchIndex - 1 + findMatches.length) % findMatches.length;
setFindState((current) => ({ ...current, currentMatchIndex: nextIndex }));
scrollMatchIntoView(findMatches, nextIndex);
return createFilePaneMatchState(findQuery, findMatches, nextIndex);
},
onClose: () => {
setFindState(EMPTY_FILE_PANE_FIND_STATE);
},
});
useEffect(() => {
setFindState((current) => {
if (!input.textRenderData || current.query.length === 0) {
return current.query.length === 0 &&
current.matches.length === 0 &&
current.currentMatchIndex === 0
? current
: EMPTY_FILE_PANE_FIND_STATE;
}
const nextMatches = findFilePaneTextMatches(input.textRenderData, current.query);
const nextMatchIndex =
nextMatches.length === 0 ? 0 : Math.min(current.currentMatchIndex, nextMatches.length - 1);
return {
query: current.query,
matches: nextMatches,
currentMatchIndex: nextMatchIndex,
};
});
}, [input.textRenderData]);
return {
findHighlightsByLine,
paneFind,
};
}
const codeLineStyles = StyleSheet.create((theme) => ({
@@ -172,20 +478,189 @@ const codeLineStyles = StyleSheet.create((theme) => ({
},
gutterText: {
color: theme.colors.foreground,
fontFamily: theme.fontFamily.mono,
fontFamily: Fonts.mono,
fontSize: theme.fontSize.code,
lineHeight: theme.fontSize.code * 1.45,
opacity: 0.4,
userSelect: "none",
},
lineText: {
fontFamily: theme.fontFamily.mono,
fontFamily: Fonts.mono,
fontSize: theme.fontSize.code,
lineHeight: theme.fontSize.code * 1.45,
flex: 1,
},
}));
function FilePaneFindBarSlot({ paneFind }: { paneFind: UsePaneFindResult }) {
return paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null;
}
function FilePaneCenterState({ children }: FilePaneCenterStateProps) {
return <View style={styles.centerState}>{children}</View>;
}
function isLineSelected(lineSelection: FileLineSelection | null, lineNumber: number): boolean {
if (!lineSelection) {
return false;
}
return lineNumber >= lineSelection.lineStart && lineNumber <= lineSelection.lineEnd;
}
function FilePaneTextPreview({
currentMatchBackgroundColor,
findHighlightsByLine,
gutterWidth,
isMarkdownFile,
isMobile,
lineSelection,
markdownParser,
markdownStyles,
matchBackgroundColor,
preview,
previewScrollRef,
scrollbar,
showDesktopWebScrollbar,
textRenderData,
textScrollRefs,
webScrollbarStyle,
}: FilePaneTextPreviewProps) {
if (isMarkdownFile) {
return (
<>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewMarkdownScrollContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
<Markdown style={markdownStyles} markdownit={markdownParser}>
{preview.content ?? ""}
</Markdown>
</RNScrollView>
{scrollbar.overlay}
</>
);
}
const lines = textRenderData?.lines ?? [
{
lineNumber: 1,
text: preview.content ?? "",
tokens: [{ text: preview.content ?? "", style: null }],
},
];
const keyedLines = lines.map((line) => ({
key: `line-${line.lineNumber}`,
line,
}));
const codeLines = (
<View>
{keyedLines.map(({ key, line }) => (
<CodeLine
key={key}
segments={createFilePaneFindTokenSegments(
line,
findHighlightsByLine.get(line.lineNumber) ?? [],
)}
lineNumber={line.lineNumber}
gutterWidth={gutterWidth}
highlighted={isLineSelected(lineSelection, line.lineNumber)}
matchBackgroundColor={matchBackgroundColor}
currentMatchBackgroundColor={currentMatchBackgroundColor}
onLineRef={textScrollRefs.registerLineRef}
/>
))}
</View>
);
return (
<>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
{isMobile ? (
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
) : (
<RNScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.previewCodeScrollContent}
>
{codeLines}
</RNScrollView>
)}
</RNScrollView>
{scrollbar.overlay}
</>
);
}
function FilePaneSearchableTextPreview(props: FilePaneSearchableTextPreviewProps) {
const { findHighlightsByLine, paneFind } = useFilePaneFindAdapter({
textRenderData: props.textRenderData,
textScrollRefs: props.textScrollRefs,
});
return (
<>
<FilePaneFindBarSlot paneFind={paneFind} />
<FilePaneTextPreview {...props} findHighlightsByLine={findHighlightsByLine} />
</>
);
}
function FilePaneImagePreview({
imagePreviewUri,
imageSource,
previewScrollRef,
scrollbar,
showDesktopWebScrollbar,
}: FilePaneImagePreviewProps) {
if (!imagePreviewUri) {
return (
<FilePaneCenterState>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text>
</FilePaneCenterState>
);
}
return (
<>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewImageScrollContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
<RNImage
source={imageSource ?? undefined}
style={styles.previewImage}
resizeMode="contain"
/>
</RNScrollView>
{scrollbar.overlay}
</>
);
}
function FilePreviewBody({
preview,
isLoading,
@@ -196,40 +671,50 @@ function FilePreviewBody({
}: FilePreviewBodyProps) {
const { theme } = useUnistyles();
const filePath = location.path;
const isDark = theme.colorScheme === "dark";
const matchBackgroundColor = isDark ? "rgba(250, 204, 21, 0.32)" : "rgba(250, 204, 21, 0.38)";
const currentMatchBackgroundColor = isDark
? "rgba(251, 146, 60, 0.58)"
: "rgba(251, 146, 60, 0.48)";
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
const isMarkdownFile =
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
const previewScrollRef = useRef<RNScrollView>(null);
const fallbackScrollRef = useRef<RNScrollView>(null);
const webScrollbarStyle = useWebScrollbarStyle();
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
enabled: showDesktopWebScrollbar,
});
const highlightedLines = useMemo(() => {
const textRenderData = useMemo(() => {
if (!preview || preview.kind !== "text" || isMarkdownFile) {
return null;
}
return highlightCode(preview.content ?? "", filePath);
return createFilePaneTextRenderData(preview.content ?? "", filePath);
}, [isMarkdownFile, preview, filePath]);
const textLineNumbers = useMemo(
() => textRenderData?.lines.map((line) => line.lineNumber) ?? null,
[textRenderData],
);
const textScrollRefs = useFilePaneTextScrollRefs(textLineNumbers);
const previewScrollRef = textRenderData ? textScrollRefs.previewScrollRef : fallbackScrollRef;
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
enabled: showDesktopWebScrollbar,
});
const gutterWidth = useMemo(() => {
if (!highlightedLines) return 0;
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.code);
}, [highlightedLines, theme.fontSize.code]);
if (!textRenderData) return 0;
return lineNumberGutterWidth(textRenderData.lines.length, theme.fontSize.code);
}, [textRenderData, theme.fontSize.code]);
const lineHeight = theme.fontSize.code * 1.45;
const lineSelection = useMemo(() => {
if (!highlightedLines) {
if (!textRenderData) {
return null;
}
return clampLineSelection({
lineStart: location.lineStart,
lineEnd: location.lineEnd,
lineCount: highlightedLines.length,
lineCount: textRenderData.lines.length,
});
}, [highlightedLines, location.lineEnd, location.lineStart]);
}, [textRenderData, location.lineEnd, location.lineStart]);
const imageSource = useMemo(
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
@@ -247,141 +732,83 @@ function FilePreviewBody({
});
}, 0);
return () => clearTimeout(timeout);
}, [lineHeight, lineSelection]);
}, [lineHeight, lineSelection, previewScrollRef]);
let content: React.ReactNode;
if (isLoading && !preview) {
return (
<View style={styles.centerState}>
content = (
<FilePaneCenterState>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text>
</View>
</FilePaneCenterState>
);
}
if (!preview) {
return (
<View style={styles.centerState}>
} else if (!preview) {
content = (
<FilePaneCenterState>
<Text style={styles.emptyText}>No preview available</Text>
</View>
</FilePaneCenterState>
);
} else if (preview.kind === "text" && textRenderData) {
content = (
<FilePaneSearchableTextPreview
currentMatchBackgroundColor={currentMatchBackgroundColor}
gutterWidth={gutterWidth}
isMarkdownFile={isMarkdownFile}
isMobile={isMobile}
lineSelection={lineSelection}
markdownParser={markdownParser}
markdownStyles={markdownStyles}
matchBackgroundColor={matchBackgroundColor}
preview={preview}
previewScrollRef={previewScrollRef}
scrollbar={scrollbar}
showDesktopWebScrollbar={showDesktopWebScrollbar}
textRenderData={textRenderData}
textScrollRefs={textScrollRefs}
webScrollbarStyle={webScrollbarStyle}
/>
);
} else if (preview.kind === "text") {
content = (
<FilePaneTextPreview
currentMatchBackgroundColor={currentMatchBackgroundColor}
findHighlightsByLine={new Map()}
gutterWidth={gutterWidth}
isMarkdownFile={isMarkdownFile}
isMobile={isMobile}
lineSelection={lineSelection}
markdownParser={markdownParser}
markdownStyles={markdownStyles}
matchBackgroundColor={matchBackgroundColor}
preview={preview}
previewScrollRef={previewScrollRef}
scrollbar={scrollbar}
showDesktopWebScrollbar={showDesktopWebScrollbar}
textRenderData={textRenderData}
textScrollRefs={textScrollRefs}
webScrollbarStyle={webScrollbarStyle}
/>
);
} else if (preview.kind === "image") {
content = (
<FilePaneImagePreview
imagePreviewUri={imagePreviewUri}
imageSource={imageSource}
previewScrollRef={previewScrollRef}
scrollbar={scrollbar}
showDesktopWebScrollbar={showDesktopWebScrollbar}
/>
);
} else {
content = (
<FilePaneCenterState>
<Text style={styles.emptyText}>Binary preview unavailable</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</FilePaneCenterState>
);
}
if (preview.kind === "text") {
if (isMarkdownFile) {
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewMarkdownScrollContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
<Markdown style={markdownStyles} markdownit={markdownParser}>
{preview.content ?? ""}
</Markdown>
</RNScrollView>
{scrollbar.overlay}
</View>
);
}
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
const keyedLines = lines.map((tokens, index) => ({
key: `line-${index}`,
tokens,
lineNumber: index + 1,
}));
const codeLines = (
<View dataSet={CODE_SURFACE_DATASET}>
{keyedLines.map(({ key, tokens, lineNumber }) => (
<CodeLine
key={key}
tokens={tokens}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
highlighted={
Boolean(lineSelection) &&
lineNumber >= (lineSelection?.lineStart ?? 0) &&
lineNumber <= (lineSelection?.lineEnd ?? 0)
}
/>
))}
</View>
);
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
{isMobile ? (
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
) : (
<RNScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.previewCodeScrollContent}
>
{codeLines}
</RNScrollView>
)}
</RNScrollView>
{scrollbar.overlay}
</View>
);
}
if (preview.kind === "image") {
if (!imagePreviewUri) {
return (
<View style={styles.centerState}>
<ActivityIndicator size="small" />
<Text style={styles.loadingText}>Loading file…</Text>
</View>
);
}
return (
<View style={styles.previewScrollContainer}>
<RNScrollView
ref={previewScrollRef}
style={styles.previewContent}
contentContainerStyle={styles.previewImageScrollContent}
onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
<RNImage
source={imageSource ?? undefined}
style={styles.previewImage}
resizeMode="contain"
/>
</RNScrollView>
{scrollbar.overlay}
</View>
);
}
return (
<View style={styles.centerState}>
<Text style={styles.emptyText}>Binary preview unavailable</Text>
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
</View>
);
return <View style={styles.previewScrollContainer}>{content}</View>;
}
export function FilePane({
@@ -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,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>
);
@@ -1,39 +1,19 @@
import { useMemo, type ReactNode } from "react";
import type { StyleProp, TextProps, TextStyle, ViewStyle } from "react-native";
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
import { UITextView } from "react-native-uitextview";
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 forwarded best-effort:
// react-native-uitextview nulls onPress on the root native view, so reliable
// tap-to-open is still tracked by #21 — but visible+selectable text beats an
// invisible link.
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) {
export function MarkdownTextSpan({ style, children }: MarkdownTextSpanProps) {
return (
<UITextView
uiTextView
selectable
style={style}
onPress={onPress}
accessibilityRole={accessibilityRole}
>
<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,23 +11,8 @@ 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 {
+194 -85
View File
@@ -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,7 +62,7 @@ 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";
@@ -125,6 +124,7 @@ interface UserMessageProps {
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
disableOuterSpacing?: boolean;
findHighlights?: MessageFindHighlight[];
}
const MessageOuterSpacingContext = createContext(false);
@@ -159,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>;
@@ -210,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;
@@ -370,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],
@@ -437,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]);
@@ -476,6 +608,7 @@ export const UserMessage = memo(function UserMessage({
isFirstInGroup = true,
isLastInGroup = true,
disableOuterSpacing,
findHighlights,
}: UserMessageProps) {
const isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
@@ -499,6 +632,10 @@ export const UserMessage = memo(function UserMessage({
},
[rewindMutation],
);
const highlightedMessageSegments = useMemo(
() => createMessageFindTextSegments(message, findHighlights),
[findHighlights, message],
);
const containerStyle = useMemo(
() => [
@@ -568,7 +705,7 @@ export const UserMessage = memo(function UserMessage({
) : null}
{hasText ? (
<Text selectable style={userMessageStylesheet.text}>
{message}
<MessageFindHighlightedText segments={highlightedMessageSegments} />
</Text>
) : null}
</View>
@@ -753,6 +890,7 @@ interface AssistantMessageProps {
serverId?: string;
client?: DaemonClient | null;
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
findHighlights?: MessageFindHighlight[];
}
export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
@@ -1488,6 +1626,7 @@ interface MemoizedMarkdownBlockProps {
rules: RenderRules;
parser: MarkdownIt;
onLinkPress: (url: string) => boolean;
findHighlights?: MessageFindHighlight[];
}
const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
@@ -1495,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}
@@ -1508,34 +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],
);
return (
<MarkdownTextSpan monoSurface={monoSurface} style={style}>
{children}
</MarkdownTextSpan>
);
const renderedChildren =
!disableFindHighlight && typeof children === "string" && renderFindHighlightedText
? renderFindHighlightedText(children)
: children;
return <MarkdownTextSpan style={style}>{renderedChildren}</MarkdownTextSpan>;
}
interface MarkdownListItemContentProps {
@@ -1568,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 });
@@ -1622,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[],
@@ -1766,7 +1878,7 @@ export const AssistantMessage = memo(function AssistantMessage({
key={node.key}
inheritedStyles={inheritedStyles}
textStyle={styles.code_inline}
monoSurface
disableFindHighlight
>
{content}
</MarkdownInheritedText>
@@ -1874,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(
() => [
@@ -1893,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}
@@ -1904,6 +2012,7 @@ export const AssistantMessage = memo(function AssistantMessage({
rules={markdownRules}
parser={markdownParser}
onLinkPress={handleMarkdownLinkPress}
findHighlights={createBlockFindHighlights(startOffset, block.length, findHighlights)}
/>
</AssistantMessageBlockContainer>
))}
@@ -1931,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,
@@ -2047,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,
},
}));
@@ -2143,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>
@@ -2179,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,
},
@@ -17,7 +17,7 @@ import {
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
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";
@@ -52,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 ? (
@@ -94,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} />
@@ -317,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>
@@ -617,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,
@@ -726,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,
@@ -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;
}
+19 -64
View File
@@ -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],
@@ -17,7 +17,6 @@ import { ProjectIconView } from "@/components/project-icon-view";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import {
memo,
useCallback,
useMemo,
useState,
@@ -28,11 +27,7 @@ import {
type Ref,
} from "react";
import { router, usePathname, type Href } from "expo-router";
import {
navigateToWorkspace,
useActiveWorkspaceSelection,
type ActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { type GestureType } from "react-native-gesture-handler";
@@ -106,6 +101,7 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { PrHint } from "@/git/use-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
@@ -177,52 +173,6 @@ function getPrIconUniMapping(state: PrHint["state"]) {
}
}
function useStableProjectIconData(
data: (string | null)[],
signature: string,
): readonly (string | null)[] {
const stableRef = useRef<{ signature: string; data: (string | null)[] } | null>(null);
if (stableRef.current?.signature !== signature) {
stableRef.current = { signature, data };
}
return stableRef.current.data;
}
function isWorkspaceSelected(input: {
selection: ActiveWorkspaceSelection | null;
serverId: string | null;
workspaceId: string;
enabled: boolean;
}): boolean {
return (
input.enabled &&
input.selection?.serverId === input.serverId &&
input.selection.workspaceId === input.workspaceId
);
}
function isProjectSelectedByRoute(input: {
selection: ActiveWorkspaceSelection | null;
project: SidebarProjectEntry;
serverId: string | null;
enabled: boolean;
}): boolean {
return (
input.enabled &&
input.selection?.serverId === input.serverId &&
input.project.workspaces.some(
(workspace) => workspace.workspaceId === input.selection?.workspaceId,
)
);
}
function selectionForSelectedWorkspace(
selected: boolean,
workspace: SidebarWorkspaceEntry,
): ActiveWorkspaceSelection | null {
return selected ? { serverId: workspace.serverId, workspaceId: workspace.workspaceId } : null;
}
interface SidebarWorkspaceListProps {
projects: SidebarProjectEntry[];
serverId: string | null;
@@ -1543,6 +1493,7 @@ function WorkspaceRowWithMenu({
isCreating?: boolean;
}) {
const toast = useToast();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const queryClient = useQueryClient();
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
@@ -1565,9 +1516,9 @@ function WorkspaceRowWithMenu({
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
activeWorkspaceSelection: selectionForSelectedWorkspace(selected, workspace),
activeWorkspaceSelection,
});
}, [selected, workspace]);
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
const archiveWorktreeAfterConfirmation = useCallback(async () => {
if (isArchiving) {
@@ -1804,14 +1755,15 @@ function NonGitProjectRowWithMenuContent({
}) {
const toast = useToast();
const contextMenu = useContextMenu();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
activeWorkspaceSelection: selectionForSelectedWorkspace(selected, workspace),
activeWorkspaceSelection,
});
}, [selected, workspace]);
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
const handleArchiveWorkspace = useCallback(() => {
if (isArchivingWorkspace) {
@@ -1931,7 +1883,6 @@ function FlattenedProjectRow({
onRemoveProject,
removeProjectStatus,
selectionEnabled,
activeWorkspaceSelection,
}: {
project: SidebarProjectEntry;
displayName: string;
@@ -1950,15 +1901,13 @@ function FlattenedProjectRow({
onRemoveProject?: () => void;
removeProjectStatus?: "idle" | "pending";
selectionEnabled: boolean;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
}) {
const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId);
const selected = isWorkspaceSelected({
selection: activeWorkspaceSelection,
serverId,
workspaceId: rowModel.workspace.workspaceId,
enabled: selectionEnabled,
});
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const selected =
selectionEnabled &&
activeWorkspaceSelection?.serverId === serverId &&
activeWorkspaceSelection.workspaceId === rowModel.workspace.workspaceId;
if (!workspace) {
return null;
@@ -2016,7 +1965,7 @@ interface WorkspaceRowItemProps {
isCreating?: boolean;
selectionEnabled: boolean;
serverId: string | null;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
currentPathname: string | null;
onWorkspacePress?: () => void;
drag?: () => void;
isDragging?: boolean;
@@ -2031,7 +1980,7 @@ function WorkspaceRowItem({
isCreating = false,
selectionEnabled,
serverId,
activeWorkspaceSelection,
currentPathname,
onWorkspacePress,
drag,
isDragging = false,
@@ -2042,8 +1991,8 @@ function WorkspaceRowItem({
return;
}
onWorkspacePress?.();
navigateToWorkspace(serverId, workspace.workspaceId);
}, [serverId, onWorkspacePress, workspace.workspaceId]);
navigateToWorkspace(serverId, workspace.workspaceId, { currentPathname });
}, [serverId, onWorkspacePress, workspace.workspaceId, currentPathname]);
return (
<WorkspaceRow
@@ -2052,12 +2001,7 @@ function WorkspaceRowItem({
showShortcutBadge={showShortcutBadge}
canCopyBranchName={canCopyBranchName}
isCreating={isCreating}
selected={isWorkspaceSelected({
selection: activeWorkspaceSelection,
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
enabled: selectionEnabled,
})}
selectionEnabled={selectionEnabled}
onPress={handlePress}
drag={drag ?? noop}
isDragging={isDragging}
@@ -2066,39 +2010,6 @@ function WorkspaceRowItem({
);
}
function areWorkspaceRowItemPropsEqual(
previous: WorkspaceRowItemProps,
next: WorkspaceRowItemProps,
): boolean {
const previousSelected = isWorkspaceSelected({
selection: previous.activeWorkspaceSelection,
serverId: previous.workspace.serverId,
workspaceId: previous.workspace.workspaceId,
enabled: previous.selectionEnabled,
});
const nextSelected = isWorkspaceSelected({
selection: next.activeWorkspaceSelection,
serverId: next.workspace.serverId,
workspaceId: next.workspace.workspaceId,
enabled: next.selectionEnabled,
});
return (
previous.workspace === next.workspace &&
previous.shortcutNumber === next.shortcutNumber &&
previous.showShortcutBadge === next.showShortcutBadge &&
previous.canCopyBranchName === next.canCopyBranchName &&
previous.isCreating === next.isCreating &&
previous.serverId === next.serverId &&
previous.onWorkspacePress === next.onWorkspacePress &&
previous.drag === next.drag &&
previous.isDragging === next.isDragging &&
previous.dragHandleProps === next.dragHandleProps &&
previousSelected === nextSelected
);
}
const MemoWorkspaceRowItem = memo(WorkspaceRowItem, areWorkspaceRowItemPropsEqual);
function WorkspaceRow({
workspace,
shortcutNumber,
@@ -2109,7 +2020,7 @@ function WorkspaceRow({
dragHandleProps,
canCopyBranchName,
isCreating = false,
selected,
selectionEnabled,
}: {
workspace: SidebarWorkspaceEntry;
shortcutNumber: number | null;
@@ -2120,9 +2031,14 @@ function WorkspaceRow({
dragHandleProps?: DraggableListDragHandleProps;
canCopyBranchName: boolean;
isCreating?: boolean;
selected: boolean;
selectionEnabled: boolean;
}) {
const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const selected =
selectionEnabled &&
activeWorkspaceSelection?.serverId === workspace.serverId &&
activeWorkspaceSelection.workspaceId === workspace.workspaceId;
if (!hydratedWorkspace) {
return null;
@@ -2158,12 +2074,12 @@ function ProjectBlock({
onWorkspacePress,
onWorkspaceReorder,
onWorktreeCreated,
currentPathname,
drag,
isDragging,
dragHandleProps,
useNestable,
creatingWorkspaceIds,
activeWorkspaceSelection,
}: {
project: SidebarProjectEntry;
collapsed: boolean;
@@ -2178,12 +2094,12 @@ function ProjectBlock({
onWorkspacePress?: () => void;
onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void;
onWorktreeCreated?: (workspaceId: string) => void;
currentPathname: string | null;
drag: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
useNestable: boolean;
creatingWorkspaceIds: ReadonlySet<string>;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
}) {
const rowModel = useMemo(
() =>
@@ -2194,12 +2110,15 @@ function ProjectBlock({
[collapsed, project],
);
const active = isProjectSelectedByRoute({
selection: activeWorkspaceSelection,
serverId,
project,
enabled: selectionEnabled,
});
const projectWorkspaceIds = useMemo(
() => project.workspaces.map((workspace) => workspace.workspaceId),
[project.workspaces],
);
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const isProjectActive =
selectionEnabled &&
activeWorkspaceSelection?.serverId === serverId &&
projectWorkspaceIds.includes(activeWorkspaceSelection.workspaceId);
const renderWorkspaceRow = useCallback(
(
@@ -2211,7 +2130,7 @@ function ProjectBlock({
},
) => {
return (
<MemoWorkspaceRowItem
<WorkspaceRowItem
workspace={item}
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
@@ -2219,7 +2138,7 @@ function ProjectBlock({
isCreating={creatingWorkspaceIds.has(item.workspaceId)}
selectionEnabled={selectionEnabled}
serverId={serverId}
activeWorkspaceSelection={activeWorkspaceSelection}
currentPathname={currentPathname}
onWorkspacePress={onWorkspacePress}
drag={input?.drag}
isDragging={input?.isDragging}
@@ -2229,8 +2148,8 @@ function ProjectBlock({
},
[
project.projectKind,
activeWorkspaceSelection,
creatingWorkspaceIds,
currentPathname,
onWorkspacePress,
serverId,
selectionEnabled,
@@ -2309,8 +2228,10 @@ function ProjectBlock({
return;
}
onWorkspacePress?.();
navigateToWorkspace(serverId, flattenedRowWorkspaceId);
}, [serverId, flattenedRowWorkspaceId, onWorkspacePress]);
navigateToWorkspace(serverId, flattenedRowWorkspaceId, {
currentPathname,
});
}, [serverId, flattenedRowWorkspaceId, onWorkspacePress, currentPathname]);
const handleToggleCollapsed = useCallback(() => {
onToggleCollapsed(project.projectKey);
@@ -2333,11 +2254,10 @@ function ProjectBlock({
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
isProjectActive={active}
isProjectActive={isProjectActive}
onRemoveProject={handleRemoveProject}
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
/>
) : (
<>
@@ -2351,7 +2271,7 @@ function ProjectBlock({
onPress={handleToggleCollapsed}
serverId={serverId}
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
isProjectActive={active}
isProjectActive={isProjectActive}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
drag={drag}
@@ -2383,46 +2303,6 @@ function ProjectBlock({
);
}
type ProjectBlockProps = Parameters<typeof ProjectBlock>[0];
function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean {
const previousActive = isProjectSelectedByRoute({
selection: previous.activeWorkspaceSelection,
project: previous.project,
serverId: previous.serverId,
enabled: previous.selectionEnabled,
});
const nextActive = isProjectSelectedByRoute({
selection: next.activeWorkspaceSelection,
project: next.project,
serverId: next.serverId,
enabled: next.selectionEnabled,
});
return (
previous.project === next.project &&
previous.collapsed === next.collapsed &&
previous.displayName === next.displayName &&
previous.iconDataUri === next.iconDataUri &&
previous.serverId === next.serverId &&
previous.selectionEnabled === next.selectionEnabled &&
previous.showShortcutBadges === next.showShortcutBadges &&
previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey &&
previous.parentGestureRef === next.parentGestureRef &&
previous.onToggleCollapsed === next.onToggleCollapsed &&
previous.onWorkspacePress === next.onWorkspacePress &&
previous.onWorkspaceReorder === next.onWorkspaceReorder &&
previous.onWorktreeCreated === next.onWorktreeCreated &&
previous.drag === next.drag &&
previous.isDragging === next.isDragging &&
previous.dragHandleProps === next.dragHandleProps &&
previous.useNestable === next.useNestable &&
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
previousActive === nextActive
);
}
const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual);
export function SidebarWorkspaceList({
projects,
serverId,
@@ -2453,7 +2333,6 @@ export function SidebarWorkspaceList({
[pathname],
);
const selectionEnabled = isWorkspaceRoute;
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const projectIconRequests = useMemo(() => {
if (!serverId) {
@@ -2495,12 +2374,6 @@ export function SidebarWorkspaceList({
})),
});
const projectIconSignature = projectIconQueries.map((query) => query.data ?? "").join("\u0000");
const projectIconData = useStableProjectIconData(
projectIconQueries.map((query) => query.data ?? null),
projectIconSignature,
);
const projectIconByProjectKey = useMemo(() => {
const iconByServerAndCwd = new Map<string, string | null>();
for (let index = 0; index < projectIconRequests.length; index += 1) {
@@ -2508,7 +2381,10 @@ export function SidebarWorkspaceList({
if (!request) {
continue;
}
iconByServerAndCwd.set(`${request.serverId}:${request.cwd}`, projectIconData[index] ?? null);
iconByServerAndCwd.set(
`${request.serverId}:${request.cwd}`,
projectIconQueries[index]?.data ?? null,
);
}
const byProject = new Map<string, string | null>();
@@ -2522,7 +2398,7 @@ export function SidebarWorkspaceList({
}
return byProject;
}, [projectIconData, projectIconRequests, projects, serverId]);
}, [projectIconQueries, projectIconRequests, projects, serverId]);
useEffect(() => {
const timeouts = creatingWorkspaceTimeoutsRef.current;
@@ -2656,7 +2532,7 @@ export function SidebarWorkspaceList({
const renderProject = useCallback(
({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) => {
return (
<MemoProjectBlock
<ProjectBlock
project={item}
collapsed={collapsedProjectKeys.has(item.projectKey)}
displayName={item.projectName}
@@ -2670,23 +2546,23 @@ export function SidebarWorkspaceList({
onWorkspacePress={onWorkspacePress}
onWorkspaceReorder={handleWorkspaceReorder}
onWorktreeCreated={handleWorktreeCreated}
currentPathname={pathname}
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
useNestable={platformIsNative}
creatingWorkspaceIds={creatingWorkspaceIds}
activeWorkspaceSelection={activeWorkspaceSelection}
/>
);
},
[
collapsedProjectKeys,
activeWorkspaceSelection,
handleWorktreeCreated,
handleWorkspaceReorder,
onWorkspacePress,
onToggleProjectCollapsed,
parentGestureRef,
pathname,
projectIconByProjectKey,
selectionEnabled,
serverId,
+130 -129
View File
@@ -67,7 +67,6 @@ import {
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
import { RenderProfile } from "@/utils/render-profiler";
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
import { isNative } from "@/constants/platform";
@@ -79,6 +78,7 @@ interface SplitContainerProps {
isWorkspaceFocused: boolean;
uiTabs: WorkspaceTab[];
hoveredCloseTabKey: string | null;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
closingTabIds: Set<string>;
onNavigateTab: (tabId: string) => void;
@@ -215,16 +215,14 @@ const MountedTabSlot = memo(function MountedTabSlot({
}, [onFocusPane, paneId]);
return (
<RenderProfile id={`DesktopMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
<View style={wrapperStyle}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
onFocusPane={handleFocusPane}
/>
</View>
</RenderProfile>
<View style={wrapperStyle}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
onFocusPane={handleFocusPane}
/>
</View>
);
});
@@ -357,6 +355,7 @@ export function SplitContainer({
isWorkspaceFocused,
uiTabs,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
closingTabIds,
onNavigateTab,
@@ -554,64 +553,63 @@ export function SplitContainer({
);
return (
<RenderProfile id="SplitContainer">
<DndContext
sensors={sensors}
collisionDetection={dropCollisionDetection}
onDragStart={handleDragStart}
onDragMove={updateDropPreview}
onDragOver={updateDropPreview}
onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd}
>
<SplitNodeView
node={renderRoot}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
/>
<DragOverlay dropAnimation={null}>
{activeDragTabId ? (
<DragOverlayTabChip
tabId={activeDragTabId}
uiTabs={uiTabs}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
) : null}
</DragOverlay>
</DndContext>
</RenderProfile>
<DndContext
sensors={sensors}
collisionDetection={dropCollisionDetection}
onDragStart={handleDragStart}
onDragMove={updateDropPreview}
onDragOver={updateDropPreview}
onDragCancel={handleDragCancel}
onDragEnd={handleDragEnd}
>
<SplitNodeView
node={renderRoot}
workspaceKey={workspaceKey}
uiTabs={uiTabs}
focusedPaneId={layout.focusedPaneId}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
activeDragTabId={activeDragTabId}
showDropZones={activeDragTabId !== null}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
/>
<DragOverlay dropAnimation={null}>
{activeDragTabId ? (
<DragOverlayTabChip
tabId={activeDragTabId}
uiTabs={uiTabs}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
/>
) : null}
</DragOverlay>
</DndContext>
);
}
@@ -713,6 +711,7 @@ function SplitNodeView({
normalizedWorkspaceId,
isWorkspaceFocused,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
closingTabIds,
onNavigateTab,
@@ -765,6 +764,7 @@ function SplitNodeView({
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
@@ -810,6 +810,7 @@ function SplitNodeView({
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isWorkspaceFocused}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={onNavigateTab}
@@ -861,6 +862,7 @@ function SplitPaneView({
normalizedWorkspaceId,
isWorkspaceFocused,
hoveredCloseTabKey,
setHoveredTabKey,
setHoveredCloseTabKey,
closingTabIds,
onNavigateTab,
@@ -993,67 +995,66 @@ function SplitPaneView({
);
return (
<RenderProfile id={`SplitPaneView:${pane.id}`}>
<View ref={paneRef} collapsable={false} style={styles.pane}>
<View style={paneTabsStyle}>
<TitlebarDragRegion />
<WorkspaceDesktopTabsRow
paneId={pane.id}
isFocused={isFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onReorderTabs={handleReorderTabs}
onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown}
externalDndContext
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
/>
</View>
<View style={styles.paneContent}>
{mountedPaneTabIds.length > 0
? mountedPaneTabIds.map((tabId) => {
const tabDescriptor = tabDescriptorMap.get(tabId);
if (!tabDescriptor) {
return null;
}
return (
<MountedTabSlot
key={tabId}
tabDescriptor={tabDescriptor}
isVisible={tabId === activeTabDescriptor?.tabId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id}
onFocusPane={stableOnFocusPane}
buildPaneContentModel={buildPaneContentModel}
/>
);
})
: (renderPaneEmptyState?.() ?? null)}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
</View>
<View ref={paneRef} collapsable={false} style={styles.pane}>
<View style={paneTabsStyle}>
<TitlebarDragRegion />
<WorkspaceDesktopTabsRow
paneId={pane.id}
isFocused={isFocused}
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onCreateBrowserTab={onCreateBrowserTab}
showCreateBrowserTab={showCreateBrowserTab}
onReorderTabs={handleReorderTabs}
onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown}
externalDndContext
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
/>
</View>
</RenderProfile>
<View style={styles.paneContent}>
{mountedPaneTabIds.length > 0
? mountedPaneTabIds.map((tabId) => {
const tabDescriptor = tabDescriptorMap.get(tabId);
if (!tabDescriptor) {
return null;
}
return (
<MountedTabSlot
key={tabId}
tabDescriptor={tabDescriptor}
isVisible={tabId === activeTabDescriptor?.tabId}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isFocused && tabId === activeTabDescriptor?.tabId}
paneId={pane.id}
onFocusPane={stableOnFocusPane}
buildPaneContentModel={buildPaneContentModel}
/>
);
})
: (renderPaneEmptyState?.() ?? null)}
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
</View>
</View>
);
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { getNativeScrollToIndexFallbackOffset } from "@/agent-stream/strategy-native";
describe("getNativeScrollToIndexFallbackOffset", () => {
it("approximates the offset for an unmeasured native FlatList row", () => {
expect(
getNativeScrollToIndexFallbackOffset({
index: 25,
averageItemLength: 72,
}),
).toBe(1800);
});
it("falls back to the start when the average item length is not useful", () => {
expect(
getNativeScrollToIndexFallbackOffset({
index: 25,
averageItemLength: 0,
}),
).toBe(0);
expect(
getNativeScrollToIndexFallbackOffset({
index: 25,
averageItemLength: Number.NaN,
}),
).toBe(0);
});
});
@@ -45,15 +45,12 @@ interface TerminalEmulatorProps {
testId?: string;
xtermTheme?: ITheme;
scrollbackLines: number;
fontFamily?: string;
fontSize?: number;
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onInput?: (data: string) => Promise<void> | void;
onFocus?: () => Promise<void> | void;
onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise<void> | void;
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
onTerminalKey?: (input: {
key: string;
ctrl: boolean;
@@ -83,8 +80,6 @@ type BridgeInboundMessage =
initialSnapshot: TerminalState | null;
scrollbackLines: number;
theme: ITheme;
fontFamily?: string;
fontSize?: number;
pendingModifiers: PendingTerminalModifiers;
swipeGesturesEnabled: boolean;
}
@@ -94,10 +89,9 @@ type BridgeInboundMessage =
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "clear"; streamKey: string }
| { type: "focus"; streamKey: string; forceRefocus?: boolean }
| { type: "resize"; streamKey: string; shouldClaim?: boolean }
| { type: "resize"; streamKey: string }
| { type: "setTheme"; streamKey: string; theme: ITheme }
| { type: "setScrollback"; streamKey: string; lines: number }
| { type: "setFont"; streamKey: string; fontFamily?: string; fontSize?: number }
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
| { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean }
| {
@@ -111,7 +105,7 @@ type BridgeOutboundMessage =
| { type: "bridgeReady" }
| { type: "rendererReady"; streamKey: string; isReady: boolean }
| { type: "input"; streamKey: string; data: string }
| { type: "resize"; streamKey: string; rows: number; cols: number; shouldClaim?: boolean }
| { type: "resize"; streamKey: string; rows: number; cols: number }
| {
type: "terminalKey";
streamKey: string;
@@ -165,8 +159,6 @@ function createMountMessage(input: {
initialSnapshot: TerminalState | null;
scrollbackLines: number;
theme: ITheme;
fontFamily?: string;
fontSize?: number;
pendingModifiers: PendingTerminalModifiers;
swipeGesturesEnabled: boolean;
}): BridgeInboundMessage {
@@ -176,8 +168,6 @@ function createMountMessage(input: {
initialSnapshot: input.initialSnapshot,
scrollbackLines: input.scrollbackLines,
theme: input.theme,
fontFamily: input.fontFamily,
fontSize: input.fontSize,
pendingModifiers: input.pendingModifiers,
swipeGesturesEnabled: input.swipeGesturesEnabled,
};
@@ -193,14 +183,11 @@ export default function TerminalEmulator({
cursor: "#e6e6e6",
},
scrollbackLines,
fontFamily,
fontSize,
swipeGesturesEnabled = false,
onSwipeLeft,
onSwipeRight,
initialSnapshot = null,
onInput,
onFocus,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
@@ -229,8 +216,6 @@ export default function TerminalEmulator({
initialSnapshot,
scrollbackLines,
theme: xtermTheme,
fontFamily,
fontSize,
pendingModifiers,
swipeGesturesEnabled,
});
@@ -239,14 +224,11 @@ export default function TerminalEmulator({
initialSnapshot,
scrollbackLines,
theme: xtermTheme,
fontFamily,
fontSize,
pendingModifiers,
swipeGesturesEnabled,
};
const callbacksRef = useRef({
onInput,
onFocus,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
@@ -259,7 +241,6 @@ export default function TerminalEmulator({
});
callbacksRef.current = {
onInput,
onFocus,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
@@ -408,11 +389,6 @@ export default function TerminalEmulator({
sendToWebView({ type: "setScrollback", streamKey, lines: scrollbackLines });
}, [scrollbackLines, sendToWebView, streamKey]);
useEffect(() => {
if (!mountedStreamKeyRef.current) return;
sendToWebView({ type: "setFont", streamKey, fontFamily, fontSize });
}, [fontFamily, fontSize, sendToWebView, streamKey]);
useEffect(() => {
if (!mountedStreamKeyRef.current) return;
sendToWebView({ type: "setPendingModifiers", streamKey, pendingModifiers });
@@ -425,14 +401,14 @@ export default function TerminalEmulator({
useEffect(() => {
if (focusRequestToken <= 0) return;
sendToWebView({ type: "resize", streamKey, shouldClaim: true });
sendToWebView({ type: "resize", streamKey });
sendToWebView({ type: "focus", streamKey });
webViewRef.current?.requestFocus();
}, [focusRequestToken, sendToWebView, streamKey]);
useEffect(() => {
if (resizeRequestToken <= 0) return;
sendToWebView({ type: "resize", streamKey, shouldClaim: true });
sendToWebView({ type: "resize", streamKey });
}, [resizeRequestToken, sendToWebView, streamKey]);
useEffect(() => {
@@ -519,11 +495,7 @@ export default function TerminalEmulator({
callbacksRef.current.onInput?.(message.data);
break;
case "resize":
callbacksRef.current.onResize?.({
rows: message.rows,
cols: message.cols,
shouldClaim: message.shouldClaim !== false,
});
callbacksRef.current.onResize?.({ rows: message.rows, cols: message.cols });
break;
case "terminalKey":
callbacksRef.current.onTerminalKey?.({
@@ -620,7 +592,6 @@ export default function TerminalEmulator({
return;
}
webViewRef.current?.requestFocus();
callbacksRef.current.onFocus?.();
sendToWebView({ type: "focus", streamKey, forceRefocus: true });
}, [sendToWebView, streamKey]);
@@ -22,6 +22,7 @@ import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-m
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import {
TerminalEmulatorRuntime,
type TerminalFindResultChangeEvent,
type TerminalOutputData,
} from "../terminal/runtime/terminal-emulator-runtime";
import type {
@@ -49,6 +50,10 @@ export interface TerminalEmulatorHandle {
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
blur: () => void;
findNext: (input: { query: string }) => boolean;
findPrevious: (input: { query: string }) => boolean;
clearFindDecorations: () => void;
onFindResultsChanged: (listener: (event: TerminalFindResultChangeEvent) => void) => () => void;
}
const SCROLLBAR_HANDLE_WIDTH_IDLE = 6;
@@ -134,15 +139,12 @@ interface TerminalEmulatorProps {
testId?: string;
xtermTheme?: ITheme;
scrollbackLines: number;
fontFamily?: string;
fontSize?: number;
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onInput?: (data: string) => Promise<void> | void;
onFocus?: () => Promise<void> | void;
onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise<void> | void;
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
onTerminalKey?: (input: {
key: string;
ctrl: boolean;
@@ -217,14 +219,11 @@ export default function TerminalEmulator({
cursor: "#e6e6e6",
},
scrollbackLines,
fontFamily,
fontSize,
swipeGesturesEnabled = false,
onSwipeLeft,
onSwipeRight,
initialSnapshot = null,
onInput,
onFocus,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
@@ -240,12 +239,8 @@ export default function TerminalEmulator({
const hostRef = useRef<HTMLDivElement | null>(null);
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
const mountedThemeRef = useRef<ITheme>(xtermTheme);
const fontFamilyRef = useRef(fontFamily);
const fontSizeRef = useRef(fontSize);
const scrollbackLinesRef = useRef(scrollbackLines);
scrollbackLinesRef.current = scrollbackLines;
fontFamilyRef.current = fontFamily;
fontSizeRef.current = fontSize;
const viewportRef = useRef<HTMLElement | null>(null);
const dragStartOffsetRef = useRef(0);
const dragStartClientYRef = useRef(0);
@@ -338,6 +333,18 @@ export default function TerminalEmulator({
blur: () => {
runtimeRef.current?.blur();
},
findNext: (input: { query: string }) => {
return runtimeRef.current?.findNext(input) ?? false;
},
findPrevious: (input: { query: string }) => {
return runtimeRef.current?.findPrevious(input) ?? false;
},
clearFindDecorations: () => {
runtimeRef.current?.clearFindDecorations();
},
onFindResultsChanged: (listener: (event: TerminalFindResultChangeEvent) => void) => {
return runtimeRef.current?.onFindResultsChanged(listener) ?? (() => {});
},
}),
[],
);
@@ -494,8 +501,6 @@ export default function TerminalEmulator({
initialSnapshot: initialSnapshotRef.current,
scrollback: scrollbackLinesRef.current,
theme: mountedThemeRef.current,
fontFamily: fontFamilyRef.current,
fontSize: fontSizeRef.current,
});
onRendererReadyChangeRef.current?.({ streamKey, isReady: true });
@@ -535,15 +540,11 @@ export default function TerminalEmulator({
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
}, [pendingModifiers]);
useEffect(() => {
runtimeRef.current?.setFont({ fontFamily, fontSize });
}, [fontFamily, fontSize]);
useEffect(() => {
if (focusRequestToken <= 0) {
return () => {};
}
runtimeRef.current?.resize({ force: true, shouldClaim: true });
runtimeRef.current?.resize({ force: true });
return focusWithRetries({
focus: () => {
runtimeRef.current?.focus();
@@ -563,7 +564,7 @@ export default function TerminalEmulator({
if (resizeRequestToken <= 0) {
return;
}
runtimeRef.current?.resize({ force: true, shouldClaim: true });
runtimeRef.current?.resize({ force: true });
}, [resizeRequestToken]);
useEffect(() => {
@@ -758,9 +759,8 @@ export default function TerminalEmulator({
}, []);
const handleRootPointerDown = useCallback(() => {
onFocus?.();
runtimeRef.current?.focus();
}, [onFocus]);
}, []);
const handleRootContextMenu = useCallback(
(event: ReactMouseEvent) => {
@@ -0,0 +1,452 @@
/**
* @vitest-environment jsdom
*/
import React, { act, useImperativeHandle } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TerminalPane } from "@/components/terminal-pane";
import type { TerminalEmulatorHandle } from "@/components/terminal-emulator";
import {
PaneFocusProvider,
PaneProvider,
createPaneFocusContextValue,
type PaneContextValue,
} from "@/panels/pane-context";
import {
clearActivePaneFindPaneId,
handlePaneFindKeyboardAction,
setActivePaneFindPaneId,
} from "@/panels/pane-find-registry";
interface FindResultChange {
resultIndex: number;
resultCount: number;
}
interface TerminalKeyInput {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}
interface MockTerminalEmulatorProps {
testId?: string;
onTerminalKey?: (input: TerminalKeyInput) => Promise<void> | void;
}
const { client, findListeners, terminalProps, theme, terminalHandle, resetTerminalHandle } =
vi.hoisted(() => {
const listeners: Array<(event: FindResultChange) => void> = [];
const handle = {
writeOutput: vi.fn(),
restoreOutput: vi.fn(),
renderSnapshot: vi.fn(),
clear: vi.fn(),
blur: vi.fn(),
findNext: vi.fn(() => true),
findPrevious: vi.fn(() => true),
clearFindDecorations: vi.fn(),
onFindResultsChanged: vi.fn((listener: (event: FindResultChange) => void) => {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener);
if (index >= 0) {
listeners.splice(index, 1);
}
};
}),
};
return {
client: {
on: vi.fn(() => () => {}),
sendTerminalInput: vi.fn(),
},
findListeners: listeners,
terminalProps: {
current: null as null | MockTerminalEmulatorProps,
},
terminalHandle: handle,
resetTerminalHandle: () => {
handle.writeOutput.mockClear();
handle.renderSnapshot.mockClear();
handle.clear.mockClear();
handle.findNext.mockClear();
handle.findPrevious.mockClear();
handle.clearFindDecorations.mockClear();
handle.onFindResultsChanged.mockClear();
listeners.splice(0);
},
theme: {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { medium: "500" as const },
borderRadius: { md: 6 },
colors: {
background: "#000",
border: "#333",
destructive: "#f43f5e",
foreground: "#fff",
foregroundMuted: "#aaa",
primary: "#0a84ff",
surface0: "#111",
surface1: "#222",
surface2: "#333",
terminal: {
background: "#000",
foreground: "#fff",
cursor: "#fff",
cursorAccent: "#000",
selectionBackground: "#444",
selectionForeground: "#fff",
black: "#000",
red: "#f00",
green: "#0f0",
yellow: "#ff0",
blue: "#00f",
magenta: "#f0f",
cyan: "#0ff",
white: "#fff",
brightBlack: "#555",
brightRed: "#f55",
brightGreen: "#5f5",
brightYellow: "#ff5",
brightBlue: "#55f",
brightMagenta: "#f5f",
brightCyan: "#5ff",
brightWhite: "#fff",
},
},
},
};
});
vi.mock("react-native", () => {
const MockView = ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
React.createElement("div", { "data-testid": testID }, 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;
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 ?? "",
});
});
return {
ActivityIndicator: () => React.createElement("span", { "data-testid": "activity" }),
Platform: {
OS: "web",
select: (options: Record<string, unknown>) => options.web ?? options.default,
},
Pressable: MockPressable,
ScrollView: MockView,
Text: MockText,
TextInput: MockTextInput,
View: MockView,
};
});
vi.mock("react-native-reanimated", () => ({
default: {
View: ({ children }: { children?: React.ReactNode }) =>
React.createElement("div", null, children),
},
runOnJS: (fn: () => void) => fn,
useAnimatedReaction: vi.fn(),
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
absoluteFillObject: {},
hairlineWidth: 1,
create: (factory: unknown) =>
Object.prototype.toString.call(factory) === "[object Function]"
? (factory as (value: unknown) => unknown)(theme)
: factory,
},
useUnistyles: () => ({ theme }),
}));
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("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => client,
useHostRuntimeIsConnected: () => true,
}));
vi.mock("@/hooks/use-app-visible", () => ({
useAppVisible: () => true,
}));
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
useKeyboardShiftStyle: () => ({
shift: { value: 0 },
style: null,
}),
}));
vi.mock("@/hooks/use-settings", () => ({
useAppSettings: () => ({ settings: { terminalScrollbackLines: 1000 } }),
}));
vi.mock("@/constants/layout", () => ({
useIsCompactFormFactor: () => false,
}));
vi.mock("@/stores/panel-store", () => ({
usePanelStore: (
selector: (state: { mobileView: string; showMobileAgentList: () => void }) => unknown,
) => selector({ mobileView: "agent", showMobileAgentList: vi.fn() }),
}));
vi.mock("@/terminal/runtime/workspace-terminal-session", () => ({
getWorkspaceTerminalSession: () => ({
snapshots: {
clear: vi.fn(),
get: vi.fn(() => null),
set: vi.fn(),
},
}),
}));
vi.mock("@/terminal/runtime/terminal-stream-controller", () => ({
TerminalStreamController: class {
dispose() {}
setTerminal() {}
},
}));
vi.mock("@/components/terminal-emulator", () => ({
default: React.forwardRef<TerminalEmulatorHandle, MockTerminalEmulatorProps>(
function TerminalEmulator(props, ref) {
terminalProps.current = props;
useImperativeHandle(ref, () => terminalHandle);
return React.createElement("div", { "data-testid": props.testId ?? "terminal-surface" });
},
),
}));
const paneInstanceId = "server-a:workspace-a:left";
const paneContext: PaneContextValue = {
serverId: "server-a",
workspaceId: "workspace-a",
paneInstanceId,
tabId: "terminal",
target: { kind: "terminal", terminalId: "terminal-a" },
openTab: () => {},
closeCurrentTab: () => {},
retargetCurrentTab: () => {},
openFileInWorkspace: () => {},
openImportSheet: () => {},
};
const paneFocus = createPaneFocusContextValue({
isPaneFocused: true,
isWorkspaceFocused: true,
});
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
resetTerminalHandle();
client.sendTerminalInput.mockClear();
client.on.mockClear();
terminalProps.current = null;
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;
vi.unstubAllGlobals();
});
function renderTerminalPane(): void {
act(() => {
root?.render(
<PaneProvider value={paneContext}>
<PaneFocusProvider value={paneFocus}>
<TerminalPane
serverId="server-a"
cwd="/repo"
terminalId="terminal-a"
isWorkspaceFocused
isPaneFocused
onOpenFileExplorer={vi.fn()}
onOpenWorkspaceFile={vi.fn()}
/>
</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 pressFindKey(key: string, shiftKey = false): void {
act(() => {
inputElement().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 emitFindResults(event: FindResultChange): void {
act(() => {
for (const listener of findListeners) {
listener(event);
}
});
}
describe("TerminalPane find", () => {
it("searches through xterm, navigates matches, and clears decorations", () => {
renderTerminalPane();
act(() => {
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
changeInput("needle");
expect(container?.textContent).toContain("Searching...");
emitFindResults({ resultIndex: 2, resultCount: 7 });
expect(terminalHandle.findNext).toHaveBeenCalledWith({ query: "needle" });
expect(container?.textContent).toContain("3 / 7");
click("pane-find-next");
expect(terminalHandle.findNext).toHaveBeenLastCalledWith({ query: "needle" });
expect(container?.textContent).toContain("Searching...");
emitFindResults({ resultIndex: 3, resultCount: 7 });
click("pane-find-prev");
expect(terminalHandle.findPrevious).toHaveBeenLastCalledWith({ query: "needle" });
emitFindResults({ resultIndex: 2, resultCount: 7 });
pressFindKey("Enter");
expect(terminalHandle.findNext).toHaveBeenLastCalledWith({ query: "needle" });
emitFindResults({ resultIndex: 3, resultCount: 7 });
pressFindKey("Enter", true);
expect(terminalHandle.findPrevious).toHaveBeenLastCalledWith({ query: "needle" });
changeInput("");
expect(terminalHandle.clearFindDecorations).toHaveBeenCalledTimes(1);
expect(container?.textContent).toContain("0 / 0");
changeInput("needle");
click("pane-find-close");
expect(terminalHandle.clearFindDecorations).toHaveBeenCalledTimes(2);
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
});
it("keeps terminal key input flowing while the find bar is open", async () => {
renderTerminalPane();
act(() => {
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
await act(async () => {
await terminalProps.current?.onTerminalKey?.({
key: "c",
ctrl: true,
shift: false,
alt: false,
meta: false,
});
});
expect(client.sendTerminalInput).toHaveBeenCalledWith("terminal-a", {
type: "input",
data: "\u0003",
});
});
});
+128 -65
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActivityIndicator,
Pressable,
@@ -47,6 +47,8 @@ import {
type OpenFileDisposition,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
import { FindBar, usePaneFind, type PaneFindMatchState } from "@/panels/pane-find";
import type { TerminalFindResultChangeEvent } from "@/terminal/runtime/terminal-emulator-runtime";
interface TerminalPaneProps {
serverId: string;
@@ -106,10 +108,25 @@ const EMPTY_MODIFIERS: ModifierState = {
alt: false,
};
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" };
function terminalScopeKey(input: { serverId: string; cwd: string }): string {
return `${input.serverId}:${input.cwd}`;
}
function terminalFindStateFromResult(event: TerminalFindResultChangeEvent): PaneFindMatchState {
if (event.resultCount <= 0 || event.resultIndex < 0) {
return NO_FIND_MATCH_STATE;
}
return {
status: "matched",
current: event.resultIndex + 1,
total: event.resultCount,
};
}
interface ModifierButtonProps {
modifier: keyof ModifierState;
active: boolean;
@@ -173,10 +190,6 @@ export function TerminalPane({
const { theme } = useUnistyles();
const { settings } = useAppSettings();
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]);
const terminalFontFamily = useMemo(() => {
const trimmed = settings.monoFontFamily.trim();
return trimmed.length > 0 ? trimmed : undefined;
}, [settings.monoFontFamily]);
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
@@ -216,10 +229,16 @@ export function TerminalPane({
win32InputMode: false,
});
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
const terminalFindQueryRef = useRef("");
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(null);
const lastPaneFocusResizeKeyRef = useRef<string | null>(null);
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
const [terminalFindMatchState, setTerminalFindMatchStateValue] =
useState<PaneFindMatchState>(EMPTY_FIND_MATCH_STATE);
const setTerminalFindMatchState = useCallback((nextState: PaneFindMatchState) => {
setTerminalFindMatchStateValue(nextState);
}, []);
useEffect(() => {
terminalIdRef.current = terminalId;
@@ -229,6 +248,19 @@ export function TerminalPane({
};
}, [terminalId]);
useEffect(() => {
if (!isWorkspaceFocused || !terminalId) {
return;
}
return emulatorRef.current?.onFindResultsChanged((event) => {
if (terminalFindQueryRef.current.length === 0) {
return;
}
setTerminalFindMatchState(terminalFindStateFromResult(event));
});
}, [isWorkspaceFocused, setTerminalFindMatchState, terminalId]);
const requestTerminalFocus = useCallback(() => {
setFocusRequestToken((current) => current + 1);
}, []);
@@ -256,8 +288,45 @@ export function TerminalPane({
[terminalId, terminalStreamKey, workspaceTerminalSession.snapshots],
);
const clearTerminalFind = useCallback(() => {
terminalFindQueryRef.current = "";
emulatorRef.current?.clearFindDecorations();
setTerminalFindMatchState(EMPTY_FIND_MATCH_STATE);
}, [setTerminalFindMatchState]);
const runTerminalFind = useCallback(
(query: string, direction: "next" | "previous"): PaneFindMatchState => {
terminalFindQueryRef.current = query;
if (query.length === 0) {
clearTerminalFind();
return EMPTY_FIND_MATCH_STATE;
}
const found =
direction === "previous"
? (emulatorRef.current?.findPrevious({ query }) ?? false)
: (emulatorRef.current?.findNext({ query }) ?? false);
if (!found) {
setTerminalFindMatchState(NO_FIND_MATCH_STATE);
return NO_FIND_MATCH_STATE;
}
setTerminalFindMatchState(PENDING_FIND_MATCH_STATE);
return PENDING_FIND_MATCH_STATE;
},
[clearTerminalFind, setTerminalFindMatchState],
);
const paneFind = usePaneFind({
matchState: terminalFindMatchState,
onQuery: (query) => runTerminalFind(query, "next"),
onNext: () => runTerminalFind(terminalFindQueryRef.current, "next"),
onPrev: () => runTerminalFind(terminalFindQueryRef.current, "previous"),
onClose: clearTerminalFind,
});
useEffect(() => {
if (isMobile || !isPaneFocused || !terminalId) {
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
lastAutoFocusKeyRef.current = null;
return;
}
@@ -268,36 +337,15 @@ export function TerminalPane({
}
lastAutoFocusKeyRef.current = nextFocusKey;
if (!isWorkspaceFocused) {
return;
}
requestTerminalFocus();
}, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]);
useEffect(() => {
if (!isPaneFocused || !terminalId) {
lastPaneFocusResizeKeyRef.current = null;
return;
if (isPaneFocused && isWorkspaceFocused && isAppVisible && terminalId) {
lastSentTerminalSizeRef.current = null;
requestTerminalReflow();
}
const focusResizeKey = `${scopeKey}:${terminalId}`;
if (lastPaneFocusResizeKeyRef.current === focusResizeKey) {
return;
}
lastPaneFocusResizeKeyRef.current = focusResizeKey;
if (!isWorkspaceFocused) {
return;
}
lastSentTerminalSizeRef.current = null;
requestTerminalReflow();
}, [isPaneFocused, isWorkspaceFocused, requestTerminalReflow, scopeKey, terminalId]);
const handleTerminalFocus = useCallback(() => {
lastSentTerminalSizeRef.current = null;
requestTerminalReflow();
}, [requestTerminalReflow]);
}, [isAppVisible, isPaneFocused, isWorkspaceFocused, requestTerminalReflow, terminalId]);
const clearKeyboardRefitTimeouts = useCallback(() => {
if (keyboardRefitTimeoutsRef.current.length === 0) {
@@ -334,6 +382,25 @@ export function TerminalPane({
[pulseKeyboardRefits],
);
useEffect(() => {
if (!isWorkspaceFocused || !terminalId) {
return;
}
// Focus transitions can temporarily report stale dimensions.
// Pulse forced refits so xterm fills the pane when returning to a terminal.
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
setTimeout(() => {
requestTerminalReflow();
}, delayMs),
);
return () => {
for (const handle of timeoutHandles) {
clearTimeout(handle);
}
};
}, [isWorkspaceFocused, requestTerminalReflow, terminalId]);
useEffect(() => {
if (!client || !isConnected || !isWorkspaceFocused) {
return;
@@ -382,7 +449,7 @@ export function TerminalPane({
const controller = new TerminalStreamController({
client,
getPreferredSize: () => lastSentTerminalSizeRef.current,
getPreferredSize: () => measuredTerminalSizeRef.current,
onOutput: ({ terminalId: outputTerminalId, data }) => {
if (!isWorkspaceFocused || terminalIdRef.current !== outputTerminalId) {
return;
@@ -612,35 +679,33 @@ export function TerminalPane({
],
);
const handleTerminalResize = useStableEvent(
(input: { rows: number; cols: number; shouldClaim: boolean }) => {
const { rows, cols } = input;
if (rows <= 0 || cols <= 0) {
return;
}
const normalizedRows = Math.floor(rows);
const normalizedCols = Math.floor(cols);
const nextSize = { rows: normalizedRows, cols: normalizedCols };
measuredTerminalSizeRef.current = nextSize;
if (!input.shouldClaim || !client || !terminalId || !isWorkspaceFocused || !isAppVisible) {
return;
}
const previousSent = lastSentTerminalSizeRef.current;
if (
previousSent &&
previousSent.rows === normalizedRows &&
previousSent.cols === normalizedCols
) {
return;
}
lastSentTerminalSizeRef.current = nextSize;
client.sendTerminalInput(terminalId, {
type: "resize",
rows: normalizedRows,
cols: normalizedCols,
});
},
);
const handleTerminalResize = useStableEvent((input: { rows: number; cols: number }) => {
const { rows, cols } = input;
if (rows <= 0 || cols <= 0) {
return;
}
const normalizedRows = Math.floor(rows);
const normalizedCols = Math.floor(cols);
const nextSize = { rows: normalizedRows, cols: normalizedCols };
measuredTerminalSizeRef.current = nextSize;
if (!client || !terminalId || !isPaneFocused || !isWorkspaceFocused || !isAppVisible) {
return;
}
const previousSent = lastSentTerminalSizeRef.current;
if (
previousSent &&
previousSent.rows === normalizedRows &&
previousSent.cols === normalizedCols
) {
return;
}
lastSentTerminalSizeRef.current = nextSize;
client.sendTerminalInput(terminalId, {
type: "resize",
rows: normalizedRows,
cols: normalizedCols,
});
});
const handleTerminalKey = useCallback(
async (input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }) => {
@@ -757,6 +822,7 @@ export function TerminalPane({
return (
<Animated.View style={containerStyle}>
{paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null}
<View style={styles.outputContainer}>
{isWorkspaceFocused ? (
<View style={styles.terminalGestureContainer}>
@@ -767,15 +833,12 @@ export function TerminalPane({
testId="terminal-surface"
xtermTheme={xtermTheme}
scrollbackLines={settings.terminalScrollbackLines}
fontFamily={terminalFontFamily}
fontSize={settings.codeFontSize}
swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot}
onRendererReadyChange={handleRendererReadyChange}
onSwipeRight={handleSwipeRight}
onSwipeLeft={handleSwipeLeft}
onInput={handleTerminalData}
onFocus={handleTerminalFocus}
onResize={handleTerminalResize}
onTerminalKey={handleTerminalKey}
onInputModeChange={handleInputModeChange}
@@ -8,14 +8,13 @@ import {
} from "react-native";
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import { Fonts } from "@/constants/theme";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
import { highlightDiffLines } from "@/utils/diff-highlight";
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { extensionFromPath, highlightToKeyedLines } from "@/utils/highlight-cache";
import { HighlightedLines } from "./highlighted-content";
import { DiffViewer } from "./diff-viewer";
@@ -180,7 +179,7 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
style={ds.webScrollbarStyle}
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
<View style={styles.codeLine}>
<Text selectable style={styles.scrollText}>
<Text style={styles.shellPrompt}>$ </Text>
{normalizedCommand}
@@ -225,7 +224,7 @@ function WorktreeSetupDetailSection({
style={ds.webScrollbarStyle}
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
<View style={styles.codeLine}>
<Text selectable style={styles.scrollText}>
{hasLog ? setupLog : `Preparing worktree ${branchName} at ${worktreePath}`}
</Text>
@@ -384,7 +383,7 @@ function SubAgentDetailSection({
style={ds.webScrollbarStyle}
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
<View style={styles.codeLine}>
{childSessionId ? (
<Text selectable style={styles.subAgentSessionText}>
session {childSessionId}
@@ -467,7 +466,7 @@ function ScrollableTextSection({
{keyedLines ? (
<HighlightedLines lines={keyedLines} startLine={startLine} />
) : (
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
<Text selectable style={styles.scrollText}>
{content}
</Text>
)}
@@ -499,7 +498,7 @@ function FetchDetailSection({ url, result, ds }: FetchDetailProps) {
showsHorizontalScrollIndicator
style={ds.webScrollbarStyle}
>
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
<Text selectable style={styles.scrollText}>
{result ? `${url}\n\n${result}` : url}
</Text>
</ScrollView>
@@ -543,7 +542,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
showsHorizontalScrollIndicator
style={ds.webScrollbarStyle}
>
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
<Text selectable style={styles.scrollText}>
{detail.content}
</Text>
</ScrollView>
@@ -554,7 +553,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
if (detail.filePaths && detail.filePaths.length > 0) {
out.push(
<View key="search-files" style={styles.section}>
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
<Text selectable style={styles.scrollText}>
{detail.filePaths.join("\n")}
</Text>
</View>,
@@ -563,7 +562,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
if (detail.webResults && detail.webResults.length > 0) {
out.push(
<View key="search-web-results" style={styles.section}>
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
<Text selectable style={styles.scrollText}>
{detail.webResults.map((entry) => `${entry.title}\n${entry.url}`).join("\n\n")}
</Text>
</View>,
@@ -572,7 +571,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
if (detail.annotations && detail.annotations.length > 0) {
out.push(
<View key="search-annotations" style={styles.section}>
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
<Text selectable style={styles.scrollText}>
{detail.annotations.join("\n\n")}
</Text>
</View>,
@@ -639,7 +638,7 @@ function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles): ReactNod
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
<Text selectable style={styles.scrollText}>
{value}
</Text>
</ScrollView>
@@ -739,7 +738,7 @@ function ErrorSection({ errorText, ds }: { errorText: string; ds: DetailStyles }
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text selectable style={SCROLL_TEXT_ERROR_STYLE} dataSet={CODE_SURFACE_DATASET}>
<Text selectable style={SCROLL_TEXT_ERROR_STYLE}>
{errorText}
</Text>
</ScrollView>
@@ -757,15 +756,7 @@ function LoadingSkeleton({ containerStyle }: { containerStyle: StyleProp<ViewSty
);
}
export function ToolCallDetailsContent({ ...props }: ToolCallDetailsContentProps) {
return (
<AppearanceStyleBoundary>
<ToolCallDetailsContentInner {...props} />
</AppearanceStyleBoundary>
);
}
function ToolCallDetailsContentInner({
export function ToolCallDetailsContent({
detail,
errorText,
maxHeight,
@@ -832,7 +823,7 @@ const styles = StyleSheet.create((theme) => {
padding: theme.spacing[3],
},
plainText: {
fontFamily: theme.fontFamily.ui,
fontFamily: Fonts.sans,
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
lineHeight: 22,
@@ -885,7 +876,7 @@ const styles = StyleSheet.create((theme) => {
padding: insets.padding,
},
scrollText: {
fontFamily: theme.fontFamily.mono,
fontFamily: Fonts.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foreground,
lineHeight: 18,
@@ -900,7 +891,7 @@ const styles = StyleSheet.create((theme) => {
color: theme.colors.foregroundMuted,
},
subAgentSessionText: {
fontFamily: theme.fontFamily.mono,
fontFamily: Fonts.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foregroundMuted,
lineHeight: 18,
@@ -916,13 +907,13 @@ const styles = StyleSheet.create((theme) => {
gap: theme.spacing[2],
},
subAgentActionTool: {
fontFamily: theme.fontFamily.mono,
fontFamily: Fonts.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foregroundMuted,
lineHeight: 18,
},
subAgentActionSummary: {
fontFamily: theme.fontFamily.mono,
fontFamily: Fonts.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foreground,
lineHeight: 18,
@@ -3,7 +3,6 @@ import {
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
@@ -31,7 +30,6 @@ import { Check, CheckCircle } from "lucide-react-native";
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { isWeb } from "@/constants/platform";
// Action status for menu items with loading/success feedback
export type ActionStatus = "idle" | "pending" | "success";
@@ -180,19 +178,10 @@ function renderDropdownSurface(input: {
scrollable: boolean;
scrollViewportStyle: StyleProp<ViewStyle>;
content: ReactElement;
surfaceNativeID: string;
onExited: () => void;
}): ReactElement {
const {
frameStyle,
testID,
surfaceStyle,
scrollable,
scrollViewportStyle,
content,
surfaceNativeID,
onExited,
} = input;
const { frameStyle, testID, surfaceStyle, scrollable, scrollViewportStyle, content, onExited } =
input;
const body = scrollable ? (
<FloatingScrollView
@@ -210,7 +199,6 @@ function renderDropdownSurface(input: {
return (
<FloatingSurface
collapsable={false}
nativeID={surfaceNativeID}
testID={testID}
style={surfaceStyle}
frameStyle={frameStyle}
@@ -362,58 +350,16 @@ function getTransformOrigin(placement: Placement, alignment: Alignment): string
return `${vertical} ${horizontal}`;
}
const CONTENT_ENTERING_DURATION_MS = 150;
const contentEntering = new Keyframe({
0: { opacity: 0, transform: [{ scale: 0.97 }] },
100: { opacity: 1, transform: [{ scale: 1 }] },
}).duration(CONTENT_ENTERING_DURATION_MS);
}).duration(150);
const contentExiting = new Keyframe({
0: { opacity: 1, transform: [{ scale: 1 }] },
100: { opacity: 0, transform: [{ scale: 0.97 }] },
}).duration(100);
function releaseFixedMenuHeight(surfaceNativeID: string): void {
if (!isWeb) return;
document.getElementById(surfaceNativeID)?.style.removeProperty("height");
}
function useReleaseFixedMenuHeight({
contentSize,
enabled,
surfaceNativeID,
}: {
contentSize: Size | null;
enabled: boolean;
surfaceNativeID: string;
}): void {
useEffect(() => {
if (!enabled) return undefined;
// Reanimated web entering animations leave the measured menu surface with
// an inline height snapshot. Once the menu is open, height must return to
// content-sized so rows can grow in place (for example service script URLs).
const release = () => {
releaseFixedMenuHeight(surfaceNativeID);
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => releaseFixedMenuHeight(surfaceNativeID));
}
};
const timers: ReturnType<typeof setTimeout>[] = [
setTimeout(release, CONTENT_ENTERING_DURATION_MS),
];
if (contentSize) {
timers.push(setTimeout(release, 0));
}
return () => {
for (const timer of timers) clearTimeout(timer);
};
}, [contentSize, enabled, surfaceNativeID]);
}
export function DropdownMenuContent({
children,
side = "bottom",
@@ -443,7 +389,6 @@ export function DropdownMenuContent({
const { open, setOpen, triggerRef, flushPendingSelect } =
useDropdownMenuContext("DropdownMenuContent");
const [modalVisible, setModalVisible] = useState(false);
const surfaceNativeID = useId();
const webScrollbarStyle = useWebScrollbarStyle();
const [closing, setClosing] = useState(false);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
@@ -484,12 +429,6 @@ export function DropdownMenuContent({
}
}, [flushPendingSelect, modalVisible, open]);
useReleaseFixedMenuHeight({
contentSize,
enabled: modalVisible,
surfaceNativeID,
});
const handleClose = useCallback(() => {
setOpen(false);
}, [setOpen]);
@@ -636,7 +575,6 @@ export function DropdownMenuContent({
scrollable,
scrollViewportStyle,
content,
surfaceNativeID,
onExited: () => setModalVisible(false),
})
: null}
@@ -97,7 +97,6 @@ interface ControlledAgentControlsProps {
/** Extra elements rendered inline with the agent controls (desktop only). */
desktopExtras?: ReactNode;
modelSelectorServerId?: string | null;
isCompactLayout?: boolean;
}
export interface DraftAgentControlsProps {
@@ -125,14 +124,12 @@ export interface DraftAgentControlsProps {
isRetryingModelProvider?: boolean;
disabled?: boolean;
modelSelectorServerId?: string | null;
isCompactLayout?: boolean;
}
interface AgentControlsProps {
agentId: string;
serverId: string;
onDropdownClose?: () => void;
isCompactLayout?: boolean;
}
function findOptionLabel(
@@ -412,11 +409,9 @@ function ControlledAgentControls({
isRetryingModelProvider = false,
desktopExtras,
modelSelectorServerId = null,
isCompactLayout,
}: ControlledAgentControlsProps) {
const { theme } = useUnistyles();
const isCompactFormFactor = useIsCompactFormFactor();
const isCompact = isCompactLayout ?? isCompactFormFactor;
const isCompact = useIsCompactFormFactor();
const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null);
const [openSelector, setOpenSelector] = useState<AgentControlSelector | null>(null);
@@ -1350,7 +1345,6 @@ export const AgentControls = memo(function AgentControls({
agentId,
serverId,
onDropdownClose,
isCompactLayout,
}: AgentControlsProps) {
const { preferences, updatePreferences } = useFormPreferences();
const agent = useSessionStore(
@@ -1524,15 +1518,8 @@ export const AgentControls = memo(function AgentControls({
);
const modeChip = useMemo(
() => (
<AgentModeControl
serverId={serverId}
agentId={agentId}
placement="toolbar"
isCompactLayout={isCompactLayout}
/>
),
[serverId, agentId, isCompactLayout],
() => <AgentModeControl serverId={serverId} agentId={agentId} placement="toolbar" />,
[serverId, agentId],
);
if (!agent) {
@@ -1561,7 +1548,6 @@ export const AgentControls = memo(function AgentControls({
disabled={!client}
desktopExtras={modeChip}
modelSelectorServerId={serverId}
isCompactLayout={isCompactLayout}
/>
);
});
@@ -1591,11 +1577,9 @@ export function DraftAgentControls({
isRetryingModelProvider = false,
disabled = false,
modelSelectorServerId = null,
isCompactLayout,
}: DraftAgentControlsProps) {
const { preferences, updatePreferences } = useFormPreferences();
const isCompactFormFactor = useIsCompactFormFactor();
const isCompact = isCompactLayout ?? isCompactFormFactor;
const isCompact = useIsCompactFormFactor();
const mappedThinkingOptions = useMemo<AgentControlOption[]>(() => {
return toThinkingControlOptions(thinkingOptions);
@@ -1641,18 +1625,9 @@ export function DraftAgentControls({
selectedMode={selectedMode}
onSelectMode={onSelectMode}
disabled={disabled}
isCompactLayout={isCompactLayout}
/>
),
[
selectedProvider,
providerDefinitions,
modeOptions,
selectedMode,
onSelectMode,
disabled,
isCompactLayout,
],
[selectedProvider, providerDefinitions, modeOptions, selectedMode, onSelectMode, disabled],
);
if (!isCompact) {
@@ -1686,7 +1661,6 @@ export function DraftAgentControls({
isRetryingModelProvider={isRetryingModelProvider}
disabled={disabled}
desktopExtras={draftModeChip}
isCompactLayout={isCompactLayout}
/>
) : null}
</View>
@@ -1714,7 +1688,6 @@ export function DraftAgentControls({
isRetryingModelProvider={isRetryingModelProvider}
disabled={disabled}
modelSelectorServerId={modelSelectorServerId}
isCompactLayout={isCompactLayout}
/>
);
}
@@ -235,17 +235,14 @@ interface AgentModeControlProps {
serverId: string;
agentId: string;
placement: AgentModeControlPlacement;
isCompactLayout?: boolean;
}
export const AgentModeControl = memo(function AgentModeControl({
serverId,
agentId,
placement,
isCompactLayout,
}: AgentModeControlProps) {
const isCompactFormFactor = useIsCompactFormFactor();
const isCompact = isCompactLayout ?? isCompactFormFactor;
const isCompact = useIsCompactFormFactor();
const slice = useSessionStore(
useShallow((state) => {
const agent = state.sessions[serverId]?.agents?.get(agentId);
@@ -306,7 +303,6 @@ export interface DraftAgentModeControlProps {
onSelectMode: (modeId: string) => void;
disabled?: boolean;
placement: AgentModeControlPlacement;
isCompactLayout?: boolean;
}
export function DraftAgentModeControl({
@@ -317,10 +313,8 @@ export function DraftAgentModeControl({
onSelectMode,
disabled,
placement,
isCompactLayout,
}: DraftAgentModeControlProps) {
const isCompactFormFactor = useIsCompactFormFactor();
const isCompact = isCompactLayout ?? isCompactFormFactor;
const isCompact = useIsCompactFormFactor();
if (!selectedProvider || modeOptions.length === 0) return null;
if (!shouldRenderForPlacement(placement, isCompact)) return null;
return (
@@ -4,7 +4,6 @@ import ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import invariant from "tiny-invariant";
import { Composer } from "@/composer";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control";
@@ -37,11 +36,7 @@ import {
useWorkspaceAttachmentScopeKey,
} from "@/attachments/workspace-attachments-store";
import type { UserMessageImageAttachment } from "@/types/stream";
import {
COMPACT_FORM_FACTOR_WIDTH,
MAX_CONTENT_WIDTH,
useIsCompactFormFactor,
} from "@/constants/layout";
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
@@ -385,11 +380,7 @@ export function WorkspaceDraftAgentTab({
};
}, [pendingAutoSubmit, pendingCreateAttempt]);
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
const isCompactFormFactor = useIsCompactFormFactor();
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
COMPACT_FORM_FACTOR_WIDTH,
{ initialIsBelow: isCompactFormFactor },
);
const isCompact = useIsCompactFormFactor();
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
serverId,
cwd: composerState.workingDir,
@@ -410,14 +401,14 @@ export function WorkspaceDraftAgentTab({
};
openFileExplorerForCheckout({
checkout,
isCompact: isCompactFormFactor,
isCompact,
});
setExplorerTabForCheckout({
...checkout,
tab: "changes",
});
},
[isCompactFormFactor, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
[isCompact, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
);
const {
@@ -634,14 +625,10 @@ export function WorkspaceDraftAgentTab({
);
const composerFooter = useMemo(
() =>
isCompactComposerLayout ? (
<DraftAgentModeControl
placement="footer"
{...composerAgentControls}
isCompactLayout={isCompactComposerLayout}
/>
isCompact ? (
<DraftAgentModeControl placement="footer" {...composerAgentControls} />
) : undefined,
[isCompactComposerLayout, composerAgentControls],
[isCompact, composerAgentControls],
);
return (
@@ -675,7 +662,7 @@ export function WorkspaceDraftAgentTab({
)}
</View>
<ReanimatedAnimated.View style={inputAreaWrapperStyle} onLayout={onInputAreaLayout}>
<ReanimatedAnimated.View style={inputAreaWrapperStyle}>
{importPillPress ? (
<View style={styles.importPillRow}>
<View style={styles.importPillContent}>
@@ -705,7 +692,6 @@ export function WorkspaceDraftAgentTab({
commandDraftConfig={composerState.commandDraftConfig}
agentControls={composerAgentControls}
footer={composerFooter}
isCompactLayout={isCompactComposerLayout}
/>
</ReanimatedAnimated.View>
</View>
+33 -52
View File
@@ -140,10 +140,6 @@ function resolveIsDesktopWebBreakpoint(isMobile: boolean): boolean {
return isWeb && !isMobile;
}
function resolveCompactLayout(override: boolean | undefined, formFactor: boolean): boolean {
return override ?? formFactor;
}
function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string {
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER;
}
@@ -194,7 +190,6 @@ function renderContextWindowMeter(
contextWindowMaxTokens: number | null,
contextWindowUsedTokens: number | null,
totalCostUsd: number | null,
showPercentage: boolean,
): ReactElement | null {
if (contextWindowMaxTokens === null || contextWindowUsedTokens === null) {
return null;
@@ -204,7 +199,6 @@ function renderContextWindowMeter(
maxTokens={contextWindowMaxTokens}
usedTokens={contextWindowUsedTokens}
totalCostUsd={totalCostUsd}
showPercentage={showPercentage}
/>
);
}
@@ -212,13 +206,13 @@ function renderContextWindowMeter(
function resolveContextWindowPlacement(
meter: ReactElement | null,
isMobile: boolean,
): { beforeVoiceContent: ReactNode; footerInlineContent: ReactNode } {
): { beforeVoiceContent: ReactNode; footerRight: ReactNode } {
if (isMobile) {
return { beforeVoiceContent: null, footerInlineContent: meter };
return { beforeVoiceContent: null, footerRight: meter };
}
return {
beforeVoiceContent: <View style={styles.contextWindowMeterSlot}>{meter}</View>,
footerInlineContent: null,
footerRight: null,
};
}
@@ -227,22 +221,14 @@ interface RenderLeftContentArgs {
agentId: string;
serverId: string;
focusInput: () => void;
isCompactLayout: boolean;
}
function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
const { agentControls, agentId, serverId, focusInput, isCompactLayout } = args;
const { agentControls, agentId, serverId, focusInput } = args;
if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) {
return <DraftAgentControls {...agentControls} isCompactLayout={isCompactLayout} />;
return <DraftAgentControls {...agentControls} />;
}
return (
<AgentControls
agentId={agentId}
serverId={serverId}
onDropdownClose={focusInput}
isCompactLayout={isCompactLayout}
/>
);
return <AgentControls agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />;
}
interface RenderAttachmentTrayArgs {
@@ -252,18 +238,13 @@ interface RenderAttachmentTrayArgs {
handleRemoveAttachment: (index: number) => void;
}
function renderComposerFooter(
footer: ReactNode,
footerInlineContent: ReactNode,
): ReactElement | null {
if (!footer && !footerInlineContent) return null;
function renderComposerFooter(footer: ReactNode, footerRight: ReactNode): ReactElement | null {
if (!footer && !footerRight) return null;
return (
<View style={styles.footer}>
<View style={styles.footerContent}>
<View style={styles.footerLeft}>
{footer}
{footerInlineContent}
</View>
<View style={styles.footerLeft}>{footer}</View>
<View style={styles.footerRight}>{footerRight}</View>
</View>
</View>
);
@@ -686,8 +667,6 @@ interface ComposerProps {
footer?: ReactNode;
/** When true, a parent wrapper owns the keyboard shift, so the composer skips its own. */
externalKeyboardShift?: boolean;
/** Optional panel/container layout breakpoint. Defaults to the screen breakpoint. */
isCompactLayout?: boolean;
}
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
@@ -883,7 +862,6 @@ export function Composer({
inputWrapperStyle,
footer,
externalKeyboardShift,
isCompactLayout: isCompactLayoutOverride,
}: ComposerProps) {
const buttonIconSize = resolveComposerButtonIconSize();
const client = useHostRuntimeClient(serverId);
@@ -914,11 +892,9 @@ export function Composer({
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
const isCompactFormFactor = useIsCompactFormFactor();
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout);
const isMobile = useIsCompactFormFactor();
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isMobile);
const messagePlaceholder = resolveMessagePlaceholder(isDesktopWebBreakpoint);
const userInput = value;
const setUserInput = onChangeText;
const {
@@ -1454,7 +1430,7 @@ export function Composer({
isAgentRunning={isAgentRunning}
hasSendableContent={hasSendableContent}
isProcessing={isProcessing}
isCompact={isCompactLayout}
isCompact={isMobile}
buttonIconSize={buttonIconSize}
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
isConnected={isConnected}
@@ -1472,7 +1448,7 @@ export function Composer({
hasSendableContent,
isAgentRunning,
isConnected,
isCompactLayout,
isMobile,
isProcessing,
isVoiceModeForAgent,
isVoiceSwitching,
@@ -1492,13 +1468,12 @@ export function Composer({
contextWindowMaxTokens,
contextWindowUsedTokens,
agentState.totalCostUsd,
isCompactLayout,
),
[contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, isCompactLayout],
[contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd],
);
const { beforeVoiceContent, footerInlineContent } = useMemo(
() => resolveContextWindowPlacement(contextWindowMeter, isCompactLayout),
[contextWindowMeter, isCompactLayout],
const { beforeVoiceContent, footerRight } = useMemo(
() => resolveContextWindowPlacement(contextWindowMeter, isMobile),
[contextWindowMeter, isMobile],
);
const githubSearchQueryTrimmed = githubSearchQuery.trim();
@@ -1565,8 +1540,8 @@ export function Composer({
);
const leftContent = useMemo(
() => renderLeftContent({ agentControls, agentId, serverId, focusInput, isCompactLayout }),
[agentId, focusInput, serverId, agentControls, isCompactLayout],
() => renderLeftContent({ agentControls, agentId, serverId, focusInput }),
[agentId, focusInput, serverId, agentControls],
);
const handleAttachButtonRef = useCallback((node: View | null) => {
@@ -1743,7 +1718,7 @@ export function Composer({
</View>
</View>
</View>
{renderComposerFooter(footer, footerInlineContent)}
{renderComposerFooter(footer, footerRight)}
</Animated.View>
);
}
@@ -1785,10 +1760,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
md: -theme.spacing[3],
},
alignItems: "center",
paddingBottom: {
xs: 0,
md: theme.spacing[2],
},
},
footerContent: {
width: "100%",
@@ -1811,7 +1782,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
// On mobile, cancel the leading glyph's internal padding (chip paddingHorizontal)
// so its icon aligns to the composer border before the footer inset is applied.
marginLeft: {
@@ -1819,6 +1789,17 @@ const styles = StyleSheet.create((theme: Theme) => ({
md: 0,
},
},
footerRight: {
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
// On mobile, cancel the trailing glyph's internal inset (28px box around a 16px
// ring) so its right edge aligns to the composer border before the footer inset.
marginRight: {
xs: -6,
md: 0,
},
},
messageInputContainer: {
position: "relative",
width: "100%",
-1
View File
@@ -13,7 +13,6 @@ export const HEADER_TOP_PADDING_MOBILE = 8;
// Max width for chat content (stream view, input area, new agent form)
export const MAX_CONTENT_WIDTH = 820;
export const COMPACT_FORM_FACTOR_WIDTH = 500;
// Desktop app constants for macOS traffic light buttons
// These buttons (close/minimize/maximize) overlay the top-left corner
+27
View File
@@ -3,6 +3,8 @@
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
import { Platform } from "react-native";
const tintColorLight = "#0a7ea4";
const tintColorDark = "#fff";
@@ -24,3 +26,28 @@ export const Colors = {
tabIconSelected: tintColorDark,
},
};
export const Fonts = Platform.select({
ios: {
/** iOS `UIFontDescriptorSystemDesignDefault` */
sans: "system-ui",
/** iOS `UIFontDescriptorSystemDesignSerif` */
serif: "ui-serif",
/** iOS `UIFontDescriptorSystemDesignRounded` */
rounded: "ui-rounded",
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
mono: "ui-monospace",
},
default: {
sans: "normal",
serif: "serif",
rounded: "normal",
mono: "monospace",
},
web: {
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
serif: "Georgia, 'Times New Roman', serif",
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
},
});
@@ -67,10 +67,10 @@ const CATALOG_DATA = [
title: "Cline",
description:
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
version: "3",
version: "2.18.0",
iconId: "cline",
installLink: "https://cline.bot/cli",
command: ["npx", "-y", "cline@3", "--acp"],
command: ["npx", "-y", "cline@2.18.0", "--acp"],
},
{
id: "codebuddy-code",
@@ -15,7 +15,6 @@ import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
import { useDesktopSettings, type DesktopSettings } from "@/desktop/settings/desktop-settings";
import { resolveAppVersion } from "@/utils/app-version";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
type DesktopDaemonSettings = DesktopSettings["daemon"];
@@ -134,7 +133,7 @@ function DaemonLogsModal({ visible, onClose, daemonLogs }: DaemonLogsModalProps)
>
<View style={styles.modalBody}>
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable"}</Text>
<Text style={styles.logOutput} selectable dataSet={CODE_SURFACE_DATASET}>
<Text style={styles.logOutput} selectable>
{daemonLogs?.contents?.length ? daemonLogs.contents : "(log file is empty)"}
</Text>
</View>
@@ -164,7 +163,7 @@ function DaemonCliStatusModal({
snapPoints={CLI_STATUS_MODAL_SNAP_POINTS}
>
<View style={styles.modalBody}>
<Text style={styles.logOutput} selectable dataSet={CODE_SURFACE_DATASET}>
<Text style={styles.logOutput} selectable>
{cliStatusOutput ?? ""}
</Text>
<View style={styles.modalActions}>
+25
View File
@@ -92,10 +92,35 @@ export interface DesktopBrowserShortcutEvent {
action: "focus-url";
}
export interface DesktopBrowserFindOptions {
forward?: boolean;
findNext?: boolean;
matchCase?: boolean;
}
export type DesktopBrowserFindAction = "clearSelection" | "keepSelection" | "activateSelection";
export interface DesktopBrowserFoundInPageResult {
requestId?: number;
activeMatchOrdinal?: number;
matches?: number;
finalUpdate?: boolean;
}
export interface DesktopBrowserBridge {
setWorkspaceActiveBrowser?: (browserId: string | null) => Promise<void>;
openDevTools?: (browserId: string) => Promise<unknown>;
clearPartition?: (browserId: string) => Promise<void>;
findInPage: (
browserId: string,
text: string,
options?: DesktopBrowserFindOptions,
) => Promise<number | null> | number | null;
stopFindInPage: (browserId: string, action: DesktopBrowserFindAction) => Promise<void> | void;
onFoundInPage: (
browserId: string,
listener: (result: DesktopBrowserFoundInPageResult) => void,
) => Promise<() => void> | (() => void);
}
export interface DesktopInvokeBridge {
+6 -6
View File
@@ -57,8 +57,8 @@ import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { DiffScroll } from "@/components/diff-scroll";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll";
import {
buildSplitDiffRows,
@@ -955,7 +955,7 @@ function DiffFileBody({
if (layout === "split") {
const rows = buildSplitDiffRows(file);
return (
<View style={DIFF_CONTENT_SPLIT_ROW_STYLE} dataSet={CODE_SURFACE_DATASET}>
<View style={DIFF_CONTENT_SPLIT_ROW_STYLE}>
<SplitDiffColumn
rows={rows}
side="left"
@@ -979,7 +979,7 @@ function DiffFileBody({
if (wrapLines) {
return (
<View style={styles.diffContent} dataSet={CODE_SURFACE_DATASET}>
<View style={styles.diffContent}>
<View style={styles.linesContainer}>
{computedLines.map(({ line, lineNumber, key, reviewTarget }) => (
<View key={key}>
@@ -1006,7 +1006,7 @@ function DiffFileBody({
const textViewportWidth =
scrollViewWidth > 0 ? scrollViewWidth : Math.max(0, bodyWidth - gutterWidth);
return (
<View style={DIFF_CONTENT_ROW_STYLE} dataSet={CODE_SURFACE_DATASET}>
<View style={DIFF_CONTENT_ROW_STYLE}>
<View style={styles.gutterColumn}>
{computedLines.map(({ line, lineNumber, key, reviewTarget }) => (
<View key={key}>
@@ -2557,7 +2557,7 @@ const styles = StyleSheet.create((theme) => ({
paddingRight: theme.spacing[2],
fontSize: theme.fontSize.code,
lineHeight: theme.lineHeight.diff,
fontFamily: theme.fontFamily.mono,
fontFamily: Fonts.mono,
color: theme.colors.foregroundMuted,
userSelect: "none",
},
@@ -2572,7 +2572,7 @@ const styles = StyleSheet.create((theme) => ({
paddingRight: theme.spacing[3],
fontSize: theme.fontSize.code,
lineHeight: theme.lineHeight.diff,
fontFamily: theme.fontFamily.mono,
fontFamily: Fonts.mono,
color: theme.colors.foreground,
userSelect: "text",
},
@@ -20,14 +20,11 @@ export function useContainerWidth(): {
/**
* Tracks only whether a container is narrower than a threshold.
*/
export function useContainerWidthBelow(
threshold: number,
options?: { initialIsBelow?: boolean },
): {
export function useContainerWidthBelow(threshold: number): {
onLayout: (e: LayoutChangeEvent) => void;
isBelow: boolean;
} {
const [isBelow, setIsBelow] = useState(options?.initialIsBelow ?? true);
const [isBelow, setIsBelow] = useState(true);
return {
onLayout: useCallback(
(e: LayoutChangeEvent) => {
@@ -27,6 +27,25 @@ import {
navigateToLastWorkspace,
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { handlePaneFindKeyboardAction } from "@/panels/pane-find-registry";
type KeyboardShortcutResult = ReturnType<typeof resolveKeyboardShortcut>;
function applyPendingChordEventHandling(event: KeyboardEvent, result: KeyboardShortcutResult) {
if (result.preventDefault && !result.match) {
event.preventDefault();
event.stopPropagation();
}
}
function applyHandledShortcutEventHandling(event: KeyboardEvent, result: KeyboardShortcutResult) {
if (result.preventDefault || result.match?.preventDefault) {
event.preventDefault();
}
if (result.preventDefault || result.match?.stopPropagation) {
event.stopPropagation();
}
}
export function useKeyboardShortcuts({
enabled,
@@ -94,6 +113,8 @@ export function useKeyboardShortcuts({
return false;
case "dispatch":
return keyboardActionDispatcher.dispatch(action.action);
case "pane-find-open":
return handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
case "navigate-workspace":
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
return true;
@@ -182,10 +203,7 @@ export function useKeyboardShortcuts({
chordStateRef.current = result.nextChordState;
if (result.preventDefault) {
event.preventDefault();
event.stopPropagation();
}
applyPendingChordEventHandling(event, result);
if (!result.match) {
return;
@@ -208,12 +226,7 @@ export function useKeyboardShortcuts({
return;
}
if (result.match.preventDefault) {
event.preventDefault();
}
if (result.match.stopPropagation) {
event.stopPropagation();
}
applyHandledShortcutEventHandling(event, result);
};
const handleKeyUp = (event: KeyboardEvent) => {
@@ -14,20 +14,12 @@ import {
APP_SETTINGS_QUERY_KEY,
DEFAULT_APP_SETTINGS,
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_TERMINAL_SCROLLBACK_LINES,
DEFAULT_UI_FONT_SIZE,
MAX_CODE_FONT_SIZE,
MAX_TERMINAL_SCROLLBACK_LINES,
MAX_UI_FONT_SIZE,
MIN_CODE_FONT_SIZE,
MIN_TERMINAL_SCROLLBACK_LINES,
MIN_UI_FONT_SIZE,
loadAppSettingsFromStorage as loadAppSettingsFromStoragePure,
loadSettingsFromStorage as loadSettingsFromStoragePure,
parseClampedFontSize,
parseTerminalScrollbackLines,
sanitizeFontFamily,
saveAppSettings as saveAppSettingsPure,
type AppSettings,
type DesktopSettingsBridge,
@@ -43,18 +35,10 @@ export {
APP_SETTINGS_KEY,
DEFAULT_APP_SETTINGS,
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_TERMINAL_SCROLLBACK_LINES,
DEFAULT_UI_FONT_SIZE,
MAX_CODE_FONT_SIZE,
MAX_TERMINAL_SCROLLBACK_LINES,
MAX_UI_FONT_SIZE,
MIN_CODE_FONT_SIZE,
MIN_TERMINAL_SCROLLBACK_LINES,
MIN_UI_FONT_SIZE,
parseClampedFontSize,
parseTerminalScrollbackLines,
sanitizeFontFamily,
};
export type {
AppSettings,
@@ -152,21 +136,6 @@ export function useSettings(): UseSettingsReturn {
if (updates.terminalScrollbackLines !== undefined) {
appUpdates.terminalScrollbackLines = updates.terminalScrollbackLines;
}
if (updates.uiFontFamily !== undefined) {
appUpdates.uiFontFamily = updates.uiFontFamily;
}
if (updates.monoFontFamily !== undefined) {
appUpdates.monoFontFamily = updates.monoFontFamily;
}
if (updates.uiFontSize !== undefined) {
appUpdates.uiFontSize = updates.uiFontSize;
}
if (updates.codeFontSize !== undefined) {
appUpdates.codeFontSize = updates.codeFontSize;
}
if (updates.syntaxTheme !== undefined) {
appUpdates.syntaxTheme = updates.syntaxTheme;
}
const promises: Promise<void>[] = [];
if (Object.keys(appUpdates).length > 0) {
promises.push(appSettings.updateSettings(appUpdates));
@@ -4,11 +4,8 @@ import {
APP_SETTINGS_KEY,
DEFAULT_APP_SETTINGS,
DEFAULT_CLIENT_SETTINGS,
DEFAULT_CODE_FONT_SIZE,
DEFAULT_UI_FONT_SIZE,
loadAppSettingsFromStorage,
loadSettingsFromStorage,
parseClampedFontSize,
parseTerminalScrollbackLines,
saveAppSettings,
type SettingsDeps,
@@ -90,8 +87,10 @@ describe("loadAppSettingsFromStorage", () => {
const result = await loadAppSettingsFromStorage(deps);
expect(result).toEqual({
...DEFAULT_CLIENT_SETTINGS,
theme: "dark",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
terminalScrollbackLines: 10_000,
});
expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe(JSON.stringify(result));
});
@@ -127,8 +126,12 @@ describe("loadSettingsFromStorage", () => {
const result = await loadSettingsFromStorage(deps);
expect(result).toEqual({
...DEFAULT_APP_SETTINGS,
theme: "light",
manageBuiltInDaemon: true,
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
terminalScrollbackLines: 10_000,
releaseChannel: "stable",
});
});
@@ -169,8 +172,10 @@ describe("loadSettingsFromStorage", () => {
{ manageBuiltInDaemon: false, releaseChannel: "beta" },
]);
expect(result).toEqual({
...DEFAULT_APP_SETTINGS,
theme: "light",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
terminalScrollbackLines: 10_000,
manageBuiltInDaemon: false,
releaseChannel: "beta",
});
@@ -189,8 +194,12 @@ describe("loadSettingsFromStorage", () => {
expect(desktop.migrationsApplied).toEqual([]);
expect(result).toEqual({
...DEFAULT_APP_SETTINGS,
theme: "light",
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
terminalScrollbackLines: 10_000,
manageBuiltInDaemon: true,
releaseChannel: "stable",
});
});
});
@@ -225,146 +234,3 @@ describe("parseTerminalScrollbackLines", () => {
expect(parseTerminalScrollbackLines("abc")).toBeNull();
});
});
describe("appearance settings", () => {
it("defaults the appearance fields when an old blob omits them", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ theme: "dark" }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.uiFontFamily).toBe("");
expect(result.monoFontFamily).toBe("");
expect(result.uiFontSize).toBe(DEFAULT_UI_FONT_SIZE);
expect(result.codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE);
expect(result.syntaxTheme).toBe("one");
});
it("clamps the UI font size into range and rejects non-numeric values", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontSize: 999 }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).uiFontSize).toBe(24);
const low = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontSize: 8 }),
}),
});
expect((await loadAppSettingsFromStorage(low)).uiFontSize).toBe(11);
const bogus = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontSize: "abc" }),
}),
});
expect((await loadAppSettingsFromStorage(bogus)).uiFontSize).toBe(DEFAULT_UI_FONT_SIZE);
});
it("clamps the code font size into range and rejects non-numeric values", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ codeFontSize: 999 }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).codeFontSize).toBe(22);
const low = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ codeFontSize: 8 }),
}),
});
expect((await loadAppSettingsFromStorage(low)).codeFontSize).toBe(9);
const bogus = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ codeFontSize: "abc" }),
}),
});
expect((await loadAppSettingsFromStorage(bogus)).codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE);
});
it("trims an accepted font family", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontFamily: " Menlo " }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).uiFontFamily).toBe("Menlo");
});
it("keeps an explicit empty font family as the default sentinel", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontFamily: "" }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).uiFontFamily).toBe("");
});
it("rejects a font family containing CSS-breaking characters", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontFamily: "a;b{c}" }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).uiFontFamily).toBe("");
});
it("rejects an over-length font family", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ uiFontFamily: "a".repeat(201) }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).uiFontFamily).toBe("");
});
it("accepts a known syntax theme id", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ syntaxTheme: "dracula" }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).syntaxTheme).toBe("dracula");
});
it("drops a removed syntax theme id back to the default", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ syntaxTheme: "auto" }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).syntaxTheme).toBe("one");
});
it("drops an unknown syntax theme id back to the default", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ syntaxTheme: "bogus" }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).syntaxTheme).toBe("one");
});
});
describe("parseClampedFontSize", () => {
it("clamps to the bounds and rejects non-numeric strings", () => {
expect(parseClampedFontSize(999, { min: 11, max: 24 })).toBe(24);
expect(parseClampedFontSize(8, { min: 11, max: 24 })).toBe(11);
expect(parseClampedFontSize("15", { min: 11, max: 24 })).toBe(15);
expect(parseClampedFontSize("abc", { min: 11, max: 24 })).toBeNull();
});
});
@@ -1,4 +1,3 @@
import { isSyntaxThemeId, type SyntaxThemeId } from "@getpaseo/highlight";
import type { QueryClient } from "@tanstack/react-query";
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
@@ -16,24 +15,12 @@ const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app"
export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000;
export const MIN_TERMINAL_SCROLLBACK_LINES = 0;
export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000;
export const DEFAULT_UI_FONT_SIZE = 16; // == FONT_SIZE.base
export const MIN_UI_FONT_SIZE = 11;
export const MAX_UI_FONT_SIZE = 24;
export const DEFAULT_CODE_FONT_SIZE = 12; // == FONT_SIZE.code
export const MIN_CODE_FONT_SIZE = 9;
export const MAX_CODE_FONT_SIZE = 22; // line-height 1.5×22=33 stays safe
export const MAX_FONT_FAMILY_LENGTH = 200;
export interface AppSettings {
theme: ThemeName | "auto";
sendBehavior: SendBehavior;
serviceUrlBehavior: ServiceUrlBehavior;
terminalScrollbackLines: number;
uiFontFamily: string; // "" = platform default UI stack
monoFontFamily: string; // "" = platform default mono stack
uiFontSize: number; // clamped px, default 16
codeFontSize: number; // clamped px, default 12
syntaxTheme: SyntaxThemeId; // default "one"
}
export interface Settings extends AppSettings {
@@ -46,11 +33,6 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
sendBehavior: "interrupt",
serviceUrlBehavior: "ask",
terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES,
uiFontFamily: "",
monoFontFamily: "",
uiFontSize: DEFAULT_UI_FONT_SIZE,
codeFontSize: DEFAULT_CODE_FONT_SIZE,
syntaxTheme: "one",
};
export const DEFAULT_APP_SETTINGS: Settings = {
@@ -162,31 +144,6 @@ function pickAppSettings(stored: Partial<AppSettings>): Partial<AppSettings> {
if (terminalScrollbackLines !== null) {
result.terminalScrollbackLines = terminalScrollbackLines;
}
const uiFontFamily = sanitizeFontFamily(stored.uiFontFamily);
if (uiFontFamily !== null) {
result.uiFontFamily = uiFontFamily;
}
const monoFontFamily = sanitizeFontFamily(stored.monoFontFamily);
if (monoFontFamily !== null) {
result.monoFontFamily = monoFontFamily;
}
const uiFontSize = parseClampedFontSize(stored.uiFontSize, {
min: MIN_UI_FONT_SIZE,
max: MAX_UI_FONT_SIZE,
});
if (uiFontSize !== null) {
result.uiFontSize = uiFontSize;
}
const codeFontSize = parseClampedFontSize(stored.codeFontSize, {
min: MIN_CODE_FONT_SIZE,
max: MAX_CODE_FONT_SIZE,
});
if (codeFontSize !== null) {
result.codeFontSize = codeFontSize;
}
if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) {
result.syntaxTheme = stored.syntaxTheme;
}
return result;
}
@@ -214,42 +171,6 @@ export function parseTerminalScrollbackLines(value: unknown): number | null {
);
}
export function parseClampedFontSize(
value: unknown,
bounds: { min: number; max: number },
): number | null {
let numericValue = NaN;
if (typeof value === "number") {
numericValue = value;
} else if (typeof value === "string" && value.trim().length > 0) {
numericValue = Number(value);
}
if (!Number.isFinite(numericValue)) {
return null;
}
return Math.min(bounds.max, Math.max(bounds.min, Math.floor(numericValue)));
}
export function sanitizeFontFamily(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (trimmed.length === 0) {
return ""; // explicit empty = default
}
if (trimmed.length > MAX_FONT_FAMILY_LENGTH) {
return null;
}
if (/[;{}<>]/.test(trimmed)) {
return null; // would break the web CSS font-family declaration
}
if ([...trimmed].some((char) => char.charCodeAt(0) <= 0x1f)) {
return null; // control chars would corrupt the font-family string
}
return trimmed; // quotes/commas are legit in stacks
}
async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): Promise<{
manageBuiltInDaemon?: boolean;
releaseChannel?: ReleaseChannel;
+1
View File
@@ -20,6 +20,7 @@ export type KeyboardActionId =
| "agent.new"
| "workspace.tab.new"
| "workspace.tab.close.current"
| "workspace.find.open"
| "workspace.tab.navigate.index"
| "workspace.tab.navigate.relative"
| "workspace.pane.split.right"
@@ -206,6 +206,18 @@ describe("keyboard-shortcuts", () => {
context: { isMac: true, isDesktop: true },
action: "workspace.tab.close.current",
},
{
name: "matches Cmd+F to open find in the focused pane",
event: { key: "f", code: "KeyF", metaKey: true },
context: { isMac: true, focusScope: "terminal" },
action: "workspace.find.open",
},
{
name: "matches Ctrl+F to open find in the focused pane on non-mac",
event: { key: "f", code: "KeyF", ctrlKey: true },
context: { isMac: false, focusScope: "terminal" },
action: "workspace.find.open",
},
{
name: "matches Ctrl+W to close current tab on non-mac desktop",
event: { key: "w", code: "KeyW", ctrlKey: true },
@@ -540,6 +552,7 @@ describe("keyboard-shortcut help sections", () => {
"workspace-jump-index": ["alt", "1-9"],
"workspace-tab-jump-index": ["alt", "shift", "1-9"],
"workspace-tab-close-current": ["alt", "shift", "W"],
"workspace-find-open": ["mod", "F"],
"workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"],
},
@@ -553,6 +566,7 @@ describe("keyboard-shortcut help sections", () => {
"workspace-jump-index": ["mod", "1-9"],
"workspace-tab-jump-index": ["mod", "alt", "1-9"],
"workspace-tab-close-current": ["meta", "W"],
"workspace-find-open": ["mod", "F"],
"workspace-pane-split-right": ["mod", "\\"],
"workspace-pane-close": ["mod", "shift", "W"],
},
@@ -250,6 +250,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
// --- Find in pane ---
{
id: "workspace-find-open-cmd-f-mac",
action: "workspace.find.open",
combo: "Cmd+F",
when: { mac: true, commandCenter: false },
help: {
id: "workspace-find-open",
section: "tabs-panes",
label: "Find in pane",
keys: ["mod", "F"],
},
},
{
id: "workspace-find-open-ctrl-f-non-mac",
action: "workspace.find.open",
combo: "Ctrl+F",
when: { mac: false, commandCenter: false },
help: {
id: "workspace-find-open",
section: "tabs-panes",
label: "Find in pane",
keys: ["mod", "F"],
},
},
// --- Workspace index jump ---
{
id: "workspace-navigate-index-cmd-digit-mac",
+4 -1
View File
@@ -37,7 +37,8 @@ export type ShortcutAction =
| { kind: "open-project-picker" }
| { kind: "callback"; name: ShortcutCallbackName }
| { kind: "command-center-toggle"; nextOpen: boolean }
| { kind: "shortcuts-dialog-toggle"; nextOpen: boolean };
| { kind: "shortcuts-dialog-toggle"; nextOpen: boolean }
| { kind: "pane-find-open" };
const NONE: ShortcutAction = { kind: "none" };
@@ -201,6 +202,8 @@ export function routeKeyboardShortcut(
return { kind: "command-center-toggle", nextOpen: !ctx.commandCenterOpen };
case "shortcuts.dialog.toggle":
return { kind: "shortcuts-dialog-toggle", nextOpen: !ctx.shortcutsDialogOpen };
case "workspace.find.open":
return { kind: "pane-find-open" };
default:
return NONE;
}
+40 -62
View File
@@ -1,6 +1,6 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { SquarePen } from "lucide-react-native";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, Text, View } from "react-native";
import ReanimatedAnimated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -22,7 +22,7 @@ import {
useWorkspaceAttachments,
useWorkspaceAttachmentScopeKey,
} from "@/attachments/workspace-attachments-store";
import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
@@ -36,10 +36,8 @@ import {
} from "@/hooks/use-agent-screen-state-machine";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { RenderProfile } from "@/utils/render-profiler";
import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor";
import {
type HostRuntimeConnectionStatus,
@@ -1097,52 +1095,42 @@ function ChatAgentReadyContent({
agentId,
}),
});
const streamSection = (
<RenderProfile id={`AgentStreamSection:${agentId}`}>
<AgentStreamSection
streamViewRef={streamViewRef}
serverId={serverId}
agentId={agentId}
agent={effectiveAgent}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
toast={panelToast.api}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</RenderProfile>
);
const composerSection = (
<RenderProfile id={`AgentComposerSection:${agentId}`}>
<AgentComposerSection
agentId={agentId}
serverId={serverId}
isPaneFocused={isPaneFocused}
isArchivingCurrentAgent={isArchivingCurrentAgent}
archivedAt={agentState.archivedAt}
cwd={cwd}
isSubmitLoading={false}
agentInputDraft={agentInputDraft}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
onAddImages={handleAddImagesCallback}
onComposerHeightChange={handleComposerHeightChange}
onMessageSent={handleMessageSent}
/>
</RenderProfile>
);
const streamContent = (
<ReanimatedAnimated.View style={animatedContentStyle}>{streamSection}</ReanimatedAnimated.View>
);
const contentContainer = <View style={styles.contentContainer}>{streamContent}</View>;
return (
<RewindComposerRestoreProvider text={agentInputDraft.text} setText={agentInputDraft.setText}>
<View style={styles.root}>
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isArchivingCurrentAgent}>
<View style={styles.container}>
{contentContainer}
<View style={styles.contentContainer}>
<ReanimatedAnimated.View style={animatedContentStyle}>
<AgentStreamSection
streamViewRef={streamViewRef}
serverId={serverId}
agentId={agentId}
agent={effectiveAgent}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
toast={panelToast.api}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</ReanimatedAnimated.View>
</View>
{composerSection}
<AgentComposerSection
agentId={agentId}
serverId={serverId}
isPaneFocused={isPaneFocused}
isArchivingCurrentAgent={isArchivingCurrentAgent}
archivedAt={agentState.archivedAt}
cwd={cwd}
isSubmitLoading={false}
agentInputDraft={agentInputDraft}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
onAddImages={handleAddImagesCallback}
onComposerHeightChange={handleComposerHeightChange}
onMessageSent={handleMessageSent}
/>
{showHistorySyncOverlay ? (
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
@@ -1170,7 +1158,7 @@ function ChatAgentReadyContent({
);
}
const AgentStreamSection = memo(function AgentStreamSection({
function AgentStreamSection({
streamViewRef,
serverId,
agentId,
@@ -1234,7 +1222,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
);
});
}
function AgentComposerSection({
agentId,
@@ -1318,11 +1306,7 @@ function ActiveAgentComposer({
onMessageSent: () => void;
}) {
const insets = useSafeAreaInsets();
const isCompactFormFactor = useIsCompactFormFactor();
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
COMPACT_FORM_FACTOR_WIDTH,
{ initialIsBelow: isCompactFormFactor },
);
const isCompact = useIsCompactFormFactor();
const paneContext = usePaneContext();
const { workspaceId, tabId, retargetCurrentTab } = paneContext;
const { archiveAgent } = useArchiveAgent();
@@ -1360,14 +1344,14 @@ function ActiveAgentComposer({
};
openFileExplorerForCheckout({
checkout,
isCompact: isCompactFormFactor,
isCompact,
});
setExplorerTabForCheckout({
...checkout,
tab: "changes",
});
},
[isCompactFormFactor, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
[isCompact, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
);
const handleClientSlashCommand = useCallback(
@@ -1419,19 +1403,14 @@ function ActiveAgentComposer({
const composerFooter = useMemo(
() =>
isCompactComposerLayout ? (
<AgentModeControl
serverId={serverId}
agentId={agentId}
placement="footer"
isCompactLayout={isCompactComposerLayout}
/>
isCompact ? (
<AgentModeControl serverId={serverId} agentId={agentId} placement="footer" />
) : undefined,
[isCompactComposerLayout, serverId, agentId],
[isCompact, serverId, agentId],
);
return (
<ReanimatedAnimated.View style={inputAreaStyle} onLayout={onInputAreaLayout}>
<ReanimatedAnimated.View style={inputAreaStyle}>
<SubagentsTrack
rows={subagentRows}
onOpenSubagent={handleOpenSubagent}
@@ -1459,7 +1438,6 @@ function ActiveAgentComposer({
onMessageSent={onMessageSent}
onClientSlashCommand={handleClientSlashCommand}
footer={composerFooter}
isCompactLayout={isCompactComposerLayout}
/>
</ReanimatedAnimated.View>
);
+1
View File
@@ -6,6 +6,7 @@ import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
export interface PaneContextValue {
serverId: string;
workspaceId: string;
paneInstanceId: string | null;
tabId: string;
target: WorkspaceTabTarget;
openTab: (target: WorkspaceTabTarget) => void;
@@ -0,0 +1,116 @@
import { describe, expect, it, vi } from "vitest";
import {
clearActivePaneFindPaneId,
createPaneFindRegistry,
handlePaneFindKeyboardAction,
paneFindRegistry,
setActivePaneFindPaneId,
type PaneFindController,
} from "@/panels/pane-find-registry";
function createController(input?: { openResult?: boolean }): PaneFindController {
return {
openFind: vi.fn(() => input?.openResult ?? true),
closeFind: vi.fn(() => true),
};
}
describe("pane find registry", () => {
it("routes open find to the active pane instance", () => {
const activePaneId = { current: "server:workspace:left" };
const registry = createPaneFindRegistry({
getActivePaneId: () => activePaneId.current,
});
const left = createController({ openResult: false });
const right = createController({ openResult: true });
registry.register({
paneId: "server:workspace:left",
controller: left,
});
registry.register({
paneId: "server:workspace:right",
controller: right,
});
expect(registry.openFindInActivePane()).toBe(false);
});
it("stops routing to a pane after it unregisters", () => {
const registry = createPaneFindRegistry({
getActivePaneId: () => "server:workspace:left",
});
const controller = createController();
const unregister = registry.register({
paneId: "server:workspace:left",
controller,
});
unregister();
expect(registry.openFindInActivePane()).toBe(false);
expect(controller.closeFind).toHaveBeenCalledTimes(1);
});
it("keeps split panes with the same target distinct by pane instance", () => {
const activePaneId = { current: "server:workspace:right" };
const registry = createPaneFindRegistry({
getActivePaneId: () => activePaneId.current,
});
const left = createController({ openResult: false });
const right = createController({ openResult: true });
registry.register({
paneId: "server:workspace:left",
controller: left,
});
registry.register({
paneId: "server:workspace:right",
controller: right,
});
expect(registry.openFindInActivePane()).toBe(true);
});
it("handles the keyboard find action through the active pane", () => {
const controller = createController();
const unregister = paneFindRegistry.register({
paneId: "server:workspace:left",
controller,
});
setActivePaneFindPaneId("server:workspace:left");
expect(handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" })).toBe(
true,
);
expect(controller.openFind).toHaveBeenCalledTimes(1);
clearActivePaneFindPaneId("server:workspace:left");
expect(controller.closeFind).toHaveBeenCalledTimes(1);
unregister();
});
it("closes the previous active pane when pane focus changes", () => {
const left = createController();
const right = createController();
const unregisterLeft = paneFindRegistry.register({
paneId: "server:workspace:left",
controller: left,
});
const unregisterRight = paneFindRegistry.register({
paneId: "server:workspace:right",
controller: right,
});
setActivePaneFindPaneId("server:workspace:left");
setActivePaneFindPaneId("server:workspace:right");
expect(left.closeFind).toHaveBeenCalledTimes(1);
expect(right.closeFind).not.toHaveBeenCalled();
clearActivePaneFindPaneId("server:workspace:right");
unregisterLeft();
unregisterRight();
});
});
@@ -0,0 +1,82 @@
export interface PaneFindController {
openFind(): boolean;
closeFind(): boolean;
}
interface PaneFindKeyboardAction {
id: "workspace.find.open";
scope: "workspace";
}
interface RegisterPaneFindInput {
paneId: string;
controller: PaneFindController;
}
interface PaneFindRegistry {
register(input: RegisterPaneFindInput): () => void;
openFindInActivePane(): boolean;
closeFindInPane(paneId: string): boolean;
}
export function createPaneFindRegistry(input: { getActivePaneId: () => string | null }) {
const controllers = new Map<string, PaneFindController>();
return {
register({ paneId, controller }: RegisterPaneFindInput) {
controllers.set(paneId, controller);
return () => {
if (controllers.get(paneId) === controller) {
controller.closeFind();
controllers.delete(paneId);
}
};
},
openFindInActivePane(): boolean {
const activePaneId = input.getActivePaneId();
if (!activePaneId) {
return false;
}
return controllers.get(activePaneId)?.openFind() ?? false;
},
closeFindInPane(paneId: string): boolean {
return controllers.get(paneId)?.closeFind() ?? false;
},
} satisfies PaneFindRegistry;
}
let activePaneId: string | null = null;
export const paneFindRegistry = createPaneFindRegistry({
getActivePaneId: () => activePaneId,
});
export function createPaneFindPaneId(input: {
serverId: string;
workspaceId: string;
paneId: string;
}): string {
return `${input.serverId}:${input.workspaceId}:${input.paneId}`;
}
export function setActivePaneFindPaneId(paneId: string | null) {
if (activePaneId && activePaneId !== paneId) {
paneFindRegistry.closeFindInPane(activePaneId);
}
activePaneId = paneId;
}
export function clearActivePaneFindPaneId(paneId: string) {
if (activePaneId === paneId) {
paneFindRegistry.closeFindInPane(paneId);
activePaneId = null;
}
}
export function handlePaneFindKeyboardAction(action: PaneFindKeyboardAction): boolean {
void action;
return paneFindRegistry.openFindInActivePane();
}
+503
View File
@@ -0,0 +1,503 @@
/**
* @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 { Text, View } from "react-native";
import {
FindBar,
usePaneFind,
type PaneFindCommandResult,
type PaneFindMatchState,
} from "@/panels/pane-find";
import {
PaneFocusProvider,
PaneProvider,
createPaneFocusContextValue,
usePaneContext,
type PaneContextValue,
} from "@/panels/pane-context";
import {
clearActivePaneFindPaneId,
createPaneFindPaneId,
handlePaneFindKeyboardAction,
setActivePaneFindPaneId,
} from "@/panels/pane-find-registry";
import {
buildWorkspacePaneContentModel,
WorkspacePaneContent,
} from "@/screens/workspace/workspace-pane-content";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
const { theme } = vi.hoisted(() => ({
theme: {
colors: {
border: "#333",
foreground: "#fff",
foregroundMuted: "#aaa",
surface0: "#111",
surface1: "#222",
},
},
}));
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", () => {
const MockView = ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
React.createElement("div", { "data-testid": testID }, 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;
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 ?? "",
});
});
return { 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("@/panels/register-panels", () => ({
ensurePanelsRegistered: vi.fn(),
}));
vi.mock("@/panels/panel-registry", () => ({
getPanelRegistration: () => ({
kind: "agent",
component: FakeFindPanel,
useDescriptor: vi.fn(),
}),
}));
interface FakeSearchController {
query: ReturnType<typeof vi.fn<(query: string) => PaneFindCommandResult>>;
next: ReturnType<typeof vi.fn<() => PaneFindCommandResult>>;
prev: ReturnType<typeof vi.fn<() => PaneFindCommandResult>>;
close: ReturnType<typeof vi.fn<() => void>>;
}
const controllers = new Map<string, FakeSearchController>();
function createController(input?: { total?: number }): FakeSearchController {
const total = input?.total ?? 3;
return {
query: vi.fn((query: string) =>
query === "missing" ? { status: "no-match" } : { status: "matched", current: 1, total },
),
next: vi.fn(() => ({ status: "matched", current: 2, total })),
prev: vi.fn(() => ({ status: "matched", current: 3, total })),
close: vi.fn(),
};
}
function FakeFindPanel() {
const paneContext = usePaneContext();
const controller = controllers.get(paneContext.paneInstanceId ?? "");
if (!controller) {
throw new Error(`Missing fake find controller for pane ${paneContext.paneInstanceId}`);
}
const paneFind = usePaneFind({
onQuery: controller.query,
onNext: controller.next,
onPrev: controller.prev,
onClose: controller.close,
});
return (
<View>
{paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null}
<Text>Pane body</Text>
</View>
);
}
function AsyncFindPanel({
matchState,
onQuery,
}: {
matchState: PaneFindMatchState;
onQuery: (query: string) => PaneFindCommandResult;
}) {
const paneFind = usePaneFind({
matchState,
onQuery,
onNext: () => undefined,
onPrev: () => undefined,
onClose: vi.fn(),
});
return (
<View>
{paneFind.isOpen ? <FindBar {...paneFind.findBarProps} /> : null}
<Text>Pane body</Text>
</View>
);
}
const tab: WorkspaceTabDescriptor = {
key: "agent_agent-a",
tabId: "agent_agent-a",
kind: "agent",
target: { kind: "agent", agentId: "agent-a" },
};
const leftPaneInstanceId = createPaneFindPaneId({
serverId: "server-a",
workspaceId: "workspace-a",
paneId: "left",
});
const harnessPaneContext: PaneContextValue = {
serverId: "server-a",
workspaceId: "workspace-a",
paneInstanceId: leftPaneInstanceId,
tabId: "agent_agent-a",
target: tab.target,
openTab: () => {},
closeCurrentTab: () => {},
retargetCurrentTab: () => {},
openFileInWorkspace: () => {},
openImportSheet: () => {},
};
const harnessPaneFocus = createPaneFocusContextValue({
isPaneFocused: true,
isWorkspaceFocused: true,
onFocusPane: () => {},
});
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
clearActivePaneFindPaneId("server-a:workspace-a:left");
clearActivePaneFindPaneId("server-a:workspace-a:right");
controllers.clear();
root = null;
container?.remove();
container = null;
vi.unstubAllGlobals();
});
function renderFindHarness(controller: FakeSearchController = createController()) {
controllers.set(leftPaneInstanceId, controller);
act(() => {
root?.render(
<PaneProvider value={harnessPaneContext}>
<PaneFocusProvider value={harnessPaneFocus}>
<FakeFindPanel />
</PaneFocusProvider>
</PaneProvider>,
);
});
setActivePaneFindPaneId(leftPaneInstanceId);
}
function inputElement(): HTMLInputElement {
const input = container?.querySelector('[data-testid="pane-find-input"]');
expect(input).toBeInstanceOf(HTMLInputElement);
return input as HTMLInputElement;
}
function button(testId: string): HTMLElement {
const element = container?.querySelector(`[data-testid="${testId}"]`);
expect(element).toBeInstanceOf(HTMLElement);
return element as HTMLElement;
}
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 }));
});
}
describe("FindBar", () => {
it("opens through pane registration and focuses the query input", () => {
renderFindHarness();
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
act(() => {
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
expect(inputElement()).toBe(document.activeElement);
expect(container?.textContent).toContain("Find");
});
it("dispatches query changes and renders match, empty, and no-match states", () => {
const controller = createController();
renderFindHarness(controller);
act(() => {
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
expect(container?.textContent).toContain("0 / 0");
changeInput("abc");
expect(controller.query).toHaveBeenLastCalledWith("abc");
expect(container?.textContent).toContain("1 / 3");
changeInput("missing");
expect(container?.textContent).toContain("No matches");
changeInput("");
expect(container?.textContent).toContain("0 / 0");
});
it("handles next, previous, Escape, Enter, Shift+Enter, and close", () => {
const controller = createController();
renderFindHarness(controller);
act(() => {
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
changeInput("abc");
pressKey("Enter");
expect(controller.next).toHaveBeenCalledTimes(1);
expect(container?.textContent).toContain("2 / 3");
pressKey("Enter", true);
expect(controller.prev).toHaveBeenCalledTimes(1);
expect(container?.textContent).toContain("3 / 3");
act(() => {
button("pane-find-next").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container?.textContent).toContain("2 / 3");
act(() => {
button("pane-find-prev").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container?.textContent).toContain("3 / 3");
pressKey("Escape");
expect(controller.close).toHaveBeenCalledTimes(1);
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
act(() => {
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
act(() => {
button("pane-find-close").dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
});
it("cleans up the active find adapter on pane deactivation and unmount", () => {
const controller = createController();
renderFindHarness(controller);
act(() => {
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
changeInput("abc");
act(() => {
clearActivePaneFindPaneId(leftPaneInstanceId);
});
expect(controller.close).toHaveBeenCalledTimes(1);
expect(container?.querySelector('[data-testid="pane-find-input"]')).toBeNull();
act(() => {
setActivePaneFindPaneId(leftPaneInstanceId);
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
changeInput("abc");
act(() => {
root?.unmount();
});
expect(controller.close).toHaveBeenCalledTimes(2);
root = null;
});
it("shows pending match metadata until an adapter-owned async result arrives", () => {
const onQuery = vi.fn(() => undefined);
let setExternalMatchState: ((matchState: PaneFindMatchState) => void) | null = null;
function Harness() {
const [externalMatchState, setMatchState] = React.useState<PaneFindMatchState>({
status: "empty",
});
setExternalMatchState = setMatchState;
return (
<PaneProvider value={harnessPaneContext}>
<PaneFocusProvider value={harnessPaneFocus}>
<AsyncFindPanel matchState={externalMatchState} onQuery={onQuery} />
</PaneFocusProvider>
</PaneProvider>
);
}
act(() => {
root?.render(<Harness />);
});
setActivePaneFindPaneId(leftPaneInstanceId);
act(() => {
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
changeInput("needle");
expect(onQuery).toHaveBeenCalledWith("needle");
expect(container?.textContent).toContain("Searching...");
act(() => {
setExternalMatchState?.({ status: "matched", current: 2, total: 4 });
});
expect(container?.textContent).toContain("2 / 4");
});
it("routes open find through the focused workspace pane without replacing pane focus", () => {
const left = createController({ total: 7 });
const right = createController({ total: 5 });
const leftContent = buildWorkspacePaneContentModel({
tab,
paneId: "left",
normalizedServerId: "server-a",
normalizedWorkspaceId: "workspace-a",
onOpenTab: vi.fn(),
onCloseCurrentTab: vi.fn(),
onRetargetCurrentTab: vi.fn(),
onOpenWorkspaceFile: vi.fn(),
onOpenImportSheet: vi.fn(),
});
const rightContent = buildWorkspacePaneContentModel({
tab,
paneId: "right",
normalizedServerId: "server-a",
normalizedWorkspaceId: "workspace-a",
onOpenTab: vi.fn(),
onCloseCurrentTab: vi.fn(),
onRetargetCurrentTab: vi.fn(),
onOpenWorkspaceFile: vi.fn(),
onOpenImportSheet: vi.fn(),
});
const focusLeft = vi.fn();
const focusRight = vi.fn();
controllers.set("server-a:workspace-a:left", left);
controllers.set("server-a:workspace-a:right", right);
act(() => {
root?.render(
<View>
<WorkspacePaneContent
content={leftContent}
isPaneFocused={false}
isWorkspaceFocused
onFocusPane={focusLeft}
/>
<WorkspacePaneContent
content={rightContent}
isPaneFocused
isWorkspaceFocused
onFocusPane={focusRight}
/>
</View>,
);
});
act(() => {
handlePaneFindKeyboardAction({ id: "workspace.find.open", scope: "workspace" });
});
changeInput("abc");
expect(container?.textContent).toContain("1 / 5");
expect(container?.textContent).not.toContain("1 / 7");
expect(focusLeft).not.toHaveBeenCalled();
expect(focusRight).not.toHaveBeenCalled();
});
});
+310
View File
@@ -0,0 +1,310 @@
import { ChevronDown, ChevronUp, X } from "lucide-react-native";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Pressable,
Text,
TextInput,
View,
type NativeSyntheticEvent,
type TextInputKeyPressEventData,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useStableEvent } from "@/hooks/use-stable-event";
import { usePaneContext } from "@/panels/pane-context";
import { paneFindRegistry } from "@/panels/pane-find-registry";
export type PaneFindMatchState =
| { status: "empty" }
| { status: "pending" }
| { status: "no-match" }
| { status: "matched"; current: number; total: number };
export type PaneFindCommandResult = PaneFindMatchState | void;
export interface UsePaneFindInput {
matchState?: PaneFindMatchState;
onQuery(query: string): PaneFindCommandResult;
onNext(): PaneFindCommandResult;
onPrev(): PaneFindCommandResult;
onClose(): void;
}
export interface FindBarProps {
query: string;
matchState: PaneFindMatchState;
focusToken: number;
onQueryChange(query: string): void;
onNext(): void;
onPrev(): void;
onClose(): void;
}
export interface UsePaneFindResult {
isOpen: boolean;
findBarProps: FindBarProps;
}
export function usePaneFind(input: UsePaneFindInput): UsePaneFindResult {
const { paneInstanceId } = usePaneContext();
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const [matchState, setMatchState] = useState<PaneFindMatchState>({ status: "empty" });
const [focusToken, setFocusToken] = useState(0);
const onQuery = useStableEvent(input.onQuery);
const onNextInput = useStableEvent(input.onNext);
const onPrevInput = useStableEvent(input.onPrev);
const onCloseInput = useStableEvent(input.onClose);
const isOpenRef = useRef(false);
const queryRef = useRef("");
useEffect(() => {
isOpenRef.current = isOpen;
}, [isOpen]);
useEffect(() => {
queryRef.current = query;
}, [query]);
useEffect(() => {
if (!isOpen || !input.matchState) {
return;
}
setMatchState(input.matchState);
}, [input.matchState, isOpen]);
const openFind = useCallback(() => {
isOpenRef.current = true;
setIsOpen(true);
setFocusToken((current) => current + 1);
return true;
}, []);
const closeFind = useCallback(() => {
if (!isOpenRef.current && queryRef.current.length === 0) {
return false;
}
isOpenRef.current = false;
queryRef.current = "";
setIsOpen(false);
setQuery("");
setMatchState({ status: "empty" });
onCloseInput();
return true;
}, [onCloseInput]);
useEffect(() => {
if (!paneInstanceId) {
return;
}
return paneFindRegistry.register({
paneId: paneInstanceId,
controller: { openFind, closeFind },
});
}, [closeFind, openFind, paneInstanceId]);
useEffect(() => {
return () => {
closeFind();
};
}, [closeFind]);
const handleQueryChange = useCallback(
(nextQuery: string) => {
queryRef.current = nextQuery;
setQuery(nextQuery);
const nextState = onQuery(nextQuery);
setMatchState(
nextQuery.length === 0 ? { status: "empty" } : (nextState ?? { status: "pending" }),
);
},
[onQuery],
);
const handleNext = useCallback(() => {
if (queryRef.current.length === 0) {
return;
}
setMatchState(onNextInput() ?? { status: "pending" });
}, [onNextInput]);
const handlePrev = useCallback(() => {
if (queryRef.current.length === 0) {
return;
}
setMatchState(onPrevInput() ?? { status: "pending" });
}, [onPrevInput]);
const handleClose = useCallback(() => {
closeFind();
}, [closeFind]);
const findBarProps = useMemo(
() => ({
query,
matchState,
focusToken,
onQueryChange: handleQueryChange,
onNext: handleNext,
onPrev: handlePrev,
onClose: handleClose,
}),
[focusToken, handleClose, handleNext, handlePrev, handleQueryChange, matchState, query],
);
return { isOpen, findBarProps };
}
export function FindBar({
query,
matchState,
focusToken,
onQueryChange,
onNext,
onPrev,
onClose,
}: FindBarProps) {
const inputRef = useRef<TextInput>(null);
const { theme } = useUnistyles();
const iconColor = theme.colors?.foregroundMuted ?? "#71717a";
useEffect(() => {
inputRef.current?.focus();
}, [focusToken]);
const canNavigate = matchState.status === "matched";
const handleKeyPress = useCallback(
(event: NativeSyntheticEvent<TextInputKeyPressEventData & { shiftKey?: boolean }>) => {
if (event.nativeEvent.key === "Escape") {
event.preventDefault?.();
onClose();
return;
}
if (event.nativeEvent.key !== "Enter") {
return;
}
event.preventDefault?.();
if (!canNavigate) {
return;
}
if (event.nativeEvent.shiftKey) {
onPrev();
return;
}
onNext();
},
[canNavigate, onClose, onNext, onPrev],
);
const counterText = formatMatchState(matchState);
const controlDisabled = !canNavigate;
const matchButtonStyle = useMemo(
() => [styles.iconButton, controlDisabled && styles.iconButtonDisabled],
[controlDisabled],
);
return (
<View style={styles.container} testID="pane-find-bar">
<Text style={styles.label}>Find</Text>
<TextInput
ref={inputRef}
testID="pane-find-input"
value={query}
onChangeText={onQueryChange}
onKeyPress={handleKeyPress}
autoCapitalize="none"
autoCorrect={false}
placeholder="Search"
placeholderTextColor={theme.colors?.foregroundMuted ?? "#71717a"}
style={styles.input}
/>
<Text style={styles.counter}>{counterText}</Text>
<Pressable
accessibilityLabel="Previous match"
disabled={controlDisabled}
onPress={onPrev}
style={matchButtonStyle}
testID="pane-find-prev"
>
<ChevronUp size={14} color={iconColor} />
</Pressable>
<Pressable
accessibilityLabel="Next match"
disabled={controlDisabled}
onPress={onNext}
style={matchButtonStyle}
testID="pane-find-next"
>
<ChevronDown size={14} color={iconColor} />
</Pressable>
<Pressable
accessibilityLabel="Close find"
onPress={onClose}
style={styles.iconButton}
testID="pane-find-close"
>
<X size={14} color={iconColor} />
</Pressable>
</View>
);
}
function formatMatchState(matchState: PaneFindMatchState): string {
if (matchState.status === "matched") {
return `${matchState.current} / ${matchState.total}`;
}
if (matchState.status === "no-match") {
return "No matches";
}
if (matchState.status === "pending") {
return "Searching...";
}
return "0 / 0";
}
const styles = StyleSheet.create((theme) => ({
container: {
minHeight: 36,
flexDirection: "row",
alignItems: "center",
gap: 6,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
paddingHorizontal: 8,
paddingVertical: 4,
},
label: {
color: theme.colors.foregroundMuted,
fontSize: 12,
fontWeight: "600",
},
input: {
flex: 1,
minWidth: 120,
height: 26,
borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.border,
borderRadius: 6,
color: theme.colors.foreground,
backgroundColor: theme.colors.surface0,
fontSize: 13,
paddingHorizontal: 8,
paddingVertical: 3,
},
counter: {
minWidth: 64,
color: theme.colors.foregroundMuted,
fontSize: 12,
textAlign: "right",
},
iconButton: {
width: 26,
height: 26,
alignItems: "center",
justifyContent: "center",
borderRadius: 6,
},
iconButtonDisabled: {
opacity: 0.4,
},
}));
+4 -4
View File
@@ -10,10 +10,10 @@ import {
} from "react-native";
import invariant from "tiny-invariant";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Fonts } from "@/constants/theme";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import type { Theme } from "@/styles/theme";
import {
useWorkspaceSetupStore,
@@ -350,7 +350,7 @@ function SetupCommandRow({
accessible
accessibilityLabel="Workspace setup log"
>
<Text selectable dataSet={CODE_SURFACE_DATASET} style={styles.logText}>
<Text selectable style={styles.logText}>
{processedLog}
</Text>
</ScrollView>
@@ -404,7 +404,7 @@ function StandaloneLogView({ commands, log }: { commands: SetupCommand[]; log: s
accessible
accessibilityLabel="Workspace setup log"
>
<Text selectable dataSet={CODE_SURFACE_DATASET} style={styles.logText}>
<Text selectable style={styles.logText}>
{log}
</Text>
</ScrollView>
@@ -540,7 +540,7 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[3],
},
logText: {
fontFamily: theme.fontFamily.mono,
fontFamily: Fonts.mono,
fontSize: theme.fontSize.code,
lineHeight: 20,
color: theme.colors.foreground,
+127 -7
View File
@@ -24,10 +24,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Buffer } from "buffer";
import {
ArrowLeft,
ChevronDown,
Sun,
Moon,
Monitor,
ChevronDown,
Settings,
Palette,
Server,
Network,
Workflow,
@@ -45,7 +46,6 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { ScreenTitle } from "@/components/headers/screen-title";
import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
import { SettingsSection } from "@/screens/settings/settings-section";
import { AppearanceSection } from "@/screens/settings/appearance/appearance-section";
import {
useAppSettings,
useSettings,
@@ -55,6 +55,7 @@ import {
type ServiceUrlBehavior,
type Settings as EffectiveSettings,
} from "@/hooks/use-settings";
import { THEME_SWATCHES } from "@/styles/theme";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
@@ -79,6 +80,7 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
@@ -132,7 +134,6 @@ interface SidebarSectionItem {
const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
{ id: "general", label: "General", icon: Settings },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard, desktopOnly: true },
{ id: "integrations", label: "Integrations", icon: Puzzle, desktopOnly: true },
{ id: "permissions", label: "Permissions", icon: Shield, desktopOnly: true },
@@ -154,9 +155,45 @@ const HOST_SECTION_ITEMS: HostSectionItem[] = [
];
// ---------------------------------------------------------------------------
// Trigger + sidebar style helpers
// Theme helpers (General section)
// ---------------------------------------------------------------------------
function ThemeIcon({
theme,
size,
color,
}: {
theme: AppSettings["theme"];
size: number;
color: string;
}) {
switch (theme) {
case "light":
return <Sun size={size} color={color} />;
case "dark":
return <Moon size={size} color={color} />;
case "auto":
return <Monitor size={size} color={color} />;
default:
return <ThemeSwatch color={THEME_SWATCHES[theme]} size={size} />;
}
}
function ThemeSwatch({ color, size }: { color: string; size: number }) {
const swatchStyle = useMemo(
() => ({
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: color,
borderWidth: 1,
borderColor: "rgba(255,255,255,0.15)",
}),
[color, size],
);
return <View style={swatchStyle} />;
}
function themeTriggerStyle({ pressed }: PressableStateCallbackType) {
return [styles.themeTrigger, pressed && { opacity: 0.85 }];
}
@@ -173,6 +210,16 @@ function selectedSidebarItemStyle({ hovered }: PressableStateCallbackType & { ho
];
}
const THEME_LABELS: Record<AppSettings["theme"], string> = {
light: "Light",
dark: "Dark",
zinc: "Zinc",
midnight: "Midnight",
claude: "Claude",
ghostty: "Ghostty",
auto: "System",
};
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
const SEND_BEHAVIOR_OPTIONS = [
@@ -200,11 +247,41 @@ const SERVICE_URL_BEHAVIOR_VALUES: ServiceUrlBehavior[] = ["ask", "in-app", "ext
interface GeneralSectionProps {
settings: AppSettings;
isDesktopApp: boolean;
handleThemeChange: (theme: AppSettings["theme"]) => void;
handleSendBehaviorChange: (behavior: SendBehavior) => void;
handleServiceUrlBehaviorChange: (behavior: ServiceUrlBehavior) => void;
handleTerminalScrollbackLinesChange: (lines: number) => void;
}
interface ThemeMenuItemProps {
themeValue: AppSettings["theme"];
selected: boolean;
iconSize: number;
iconColor: string;
onChange: (theme: AppSettings["theme"]) => void;
}
function ThemeMenuItem({
themeValue,
selected,
iconSize,
iconColor,
onChange,
}: ThemeMenuItemProps) {
const handleSelect = useCallback(() => {
onChange(themeValue);
}, [onChange, themeValue]);
const leading = useMemo(
() => <ThemeIcon theme={themeValue} size={iconSize} color={iconColor} />,
[themeValue, iconSize, iconColor],
);
return (
<DropdownMenuItem selected={selected} onSelect={handleSelect} leading={leading}>
{THEME_LABELS[themeValue]}
</DropdownMenuItem>
);
}
interface ServiceUrlBehaviorMenuItemProps {
value: ServiceUrlBehavior;
selected: boolean;
@@ -229,11 +306,13 @@ function ServiceUrlBehaviorMenuItem({
function GeneralSection({
settings,
isDesktopApp,
handleThemeChange,
handleSendBehaviorChange,
handleServiceUrlBehaviorChange,
handleTerminalScrollbackLinesChange,
}: GeneralSectionProps) {
const { theme } = useUnistyles();
const iconSize = theme.iconSize.md;
const iconColor = theme.colors.foregroundMuted;
const [terminalScrollbackValue, setTerminalScrollbackValue] = useState(
String(settings.terminalScrollbackLines),
@@ -264,6 +343,41 @@ function GeneralSection({
<SettingsSection title="General">
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Theme</Text>
</View>
<DropdownMenu>
<DropdownMenuTrigger style={themeTriggerStyle}>
<ThemeIcon theme={settings.theme} size={iconSize} color={iconColor} />
<Text style={styles.themeTriggerText}>{THEME_LABELS[settings.theme]}</Text>
<ChevronDown size={theme.iconSize.sm} color={iconColor} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
{(["light", "dark", "auto"] as const).map((t) => (
<ThemeMenuItem
key={t}
themeValue={t}
selected={settings.theme === t}
iconSize={iconSize}
iconColor={iconColor}
onChange={handleThemeChange}
/>
))}
<DropdownMenuSeparator />
{(["zinc", "midnight", "claude", "ghostty"] as const).map((t) => (
<ThemeMenuItem
key={t}
themeValue={t}
selected={settings.theme === t}
iconSize={iconSize}
iconColor={iconColor}
onChange={handleThemeChange}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
<View style={ROW_WITH_BORDER_STYLE}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Default send</Text>
<Text style={settingsStyles.rowHint}>
@@ -1103,6 +1217,13 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
return localServerId ?? sortedHosts[0]?.serverId ?? null;
}, [view, localServerId, sortedHosts]);
const handleThemeChange = useCallback(
(nextTheme: AppSettings["theme"]) => {
void updateSettings({ theme: nextTheme });
},
[updateSettings],
);
const handleSendBehaviorChange = useCallback(
(behavior: SendBehavior) => {
void updateSettings({ sendBehavior: behavior });
@@ -1325,13 +1446,12 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
<GeneralSection
settings={settings}
isDesktopApp={isDesktopApp}
handleThemeChange={handleThemeChange}
handleSendBehaviorChange={handleSendBehaviorChange}
handleServiceUrlBehaviorChange={handleServiceUrlBehaviorChange}
handleTerminalScrollbackLinesChange={handleTerminalScrollbackLinesChange}
/>
);
case "appearance":
return <AppearanceSection />;
case "shortcuts":
return isDesktopApp ? <KeyboardShortcutsSection /> : null;
case "integrations":
@@ -1,211 +0,0 @@
import { useMemo } from "react";
import { Text, View, type TextStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { HighlightToken } from "@getpaseo/highlight";
import { isWeb } from "@/constants/platform";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
import { DEFAULT_MONO_FONT_STACK } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { tokenizeToLines } from "@/utils/highlight-cache";
import { CHANGED_LINE_INDICES, PREVIEW_AFTER, PREVIEW_BEFORE } from "./preview-snippet";
// Snippets are TypeScript; the cache keys grammar selection off the extension.
const PREVIEW_EXTENSION = "ts";
// GitHub diff tints, matching git/diff-pane.tsx (addLineContainer /
// removeLineContainer). Hardcoded rgba is the documented diff exception to the
// "no raw hex outside the palette" rule (docs/design.md §13).
const REMOVED_TINT = "rgba(248, 81, 73, 0.1)";
const ADDED_TINT = "rgba(46, 160, 67, 0.15)";
// Zero-width space keeps blank lines at full line height.
const ZERO_WIDTH = "​";
type RowType = "context" | "add" | "remove";
interface PreviewOverrides {
monoFontFamily?: string;
codeFontSize?: number;
}
interface AppearancePreviewProps {
// Live draft values for the code font applied as inline overrides on top of the
// themed styles (the while-typing path). Absent/empty fields fall back to the
// theme value; an explicitly-empty family resolves to the default stack.
overrides?: PreviewOverrides;
}
function resolveFamilyOverride(value: string | undefined, fallback: string): string | undefined {
if (value === undefined) return undefined;
const trimmed = value.trim();
return trimmed.length === 0 ? fallback : trimmed;
}
function resolveSizeOverride(value: number | undefined): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function buildCodeOverride(overrides: PreviewOverrides | undefined): TextStyle {
if (!overrides) return {};
const style: TextStyle = {};
const fontFamily = resolveFamilyOverride(overrides.monoFontFamily, DEFAULT_MONO_FONT_STACK);
if (fontFamily !== undefined) style.fontFamily = fontFamily;
const fontSize = resolveSizeOverride(overrides.codeFontSize);
if (fontSize !== undefined) {
style.fontSize = fontSize;
// Mirror applyAppearance's code line-height coupling so a larger draft size
// doesn't clip while the user is still typing it.
style.lineHeight = Math.round(fontSize * 1.5);
}
// High-churn draft values bypass the Unistyles CSS registry (docs/unistyles.md).
return inlineUnistylesStyle(style);
}
interface KeyedToken {
key: string;
style: string | null;
text: string;
}
interface UnifiedRow {
key: string;
type: RowType;
marker: string;
tokens: KeyedToken[] | null;
fallbackText: string;
}
function makeRow(
key: string,
type: RowType,
marker: string,
text: string,
raw: HighlightToken[] | null,
): UnifiedRow {
const tokens =
raw && raw.length > 0
? raw.map((token, index) => ({
key: `${key}-${index}`,
style: token.style,
text: token.text,
}))
: null;
return { key, type, marker, tokens, fallbackText: text.length > 0 ? text : ZERO_WIDTH };
}
// Interleave the before/after snippet into a single unified diff: unchanged lines
// appear once as context; a changed line emits a "-" removed row (from BEFORE)
// followed by a "+" added row (from AFTER). Tokens are precomputed with stable
// keys so the renderer never keys off an array index.
function buildUnifiedRows(): UnifiedRow[] {
const beforeLines = tokenizeToLines(PREVIEW_BEFORE.join("\n"), PREVIEW_EXTENSION);
const afterLines = tokenizeToLines(PREVIEW_AFTER.join("\n"), PREVIEW_EXTENSION);
const rows: UnifiedRow[] = [];
for (let index = 0; index < PREVIEW_BEFORE.length; index += 1) {
if (CHANGED_LINE_INDICES.has(index)) {
rows.push(
makeRow(`r-${index}`, "remove", "- ", PREVIEW_BEFORE[index], beforeLines?.[index] ?? null),
);
rows.push(
makeRow(`a-${index}`, "add", "+ ", PREVIEW_AFTER[index], afterLines?.[index] ?? null),
);
} else {
rows.push(
makeRow(`c-${index}`, "context", " ", PREVIEW_BEFORE[index], beforeLines?.[index] ?? null),
);
}
}
return rows;
}
// Marker color follows the diff stat tokens; a single style ref per type keeps it
// off the new-array-as-prop path. The marker is a child of the code <Text>, so it
// inherits the mono font + (draft) size and only overrides its color.
function markerStyle(type: RowType) {
if (type === "add") return styles.markerAdd;
if (type === "remove") return styles.markerRemove;
return styles.markerContext;
}
// Self-contained live preview: a unified diff of a fixed TypeScript snippet in the
// code (mono) font with the selected syntax colors. All themed styling flows
// through StyleSheet.create((theme) => …) so it repaints when
// UnistylesRuntime.updateTheme commits a setting; the optional `overrides` layer
// inline styles for live-while-typing feedback on the code font.
export function AppearancePreview({ overrides }: AppearancePreviewProps) {
const rows = useMemo(() => buildUnifiedRows(), []);
const codeOverride = useMemo(() => buildCodeOverride(overrides), [overrides]);
const codeStyle = useMemo(() => [styles.codeLine, codeOverride], [codeOverride]);
const addRowStyle = useMemo(() => [styles.row, styles.addRow], []);
const removeRowStyle = useMemo(() => [styles.row, styles.removeRow], []);
function rowStyle(type: RowType) {
if (type === "add") return addRowStyle;
if (type === "remove") return removeRowStyle;
return styles.row;
}
return (
<View
accessibilityRole="image"
accessibilityLabel="Live preview of the syntax theme and code font"
dataSet={CODE_SURFACE_DATASET}
style={styles.card}
>
{rows.map((row) => (
<View key={row.key} style={rowStyle(row.type)}>
<Text style={codeStyle}>
<Text style={markerStyle(row.type)}>{row.marker}</Text>
{row.tokens
? row.tokens.map((token) => (
<Text
key={token.key}
style={token.style ? syntaxTokenStyleFor(token.style) : undefined}
>
{token.text}
</Text>
))
: row.fallbackText}
</Text>
</View>
))}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
card: {
backgroundColor: theme.colors.surface1,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
overflow: "hidden",
paddingVertical: theme.spacing[2],
},
row: {
paddingHorizontal: theme.spacing[3],
},
addRow: {
backgroundColor: ADDED_TINT,
},
removeRow: {
backgroundColor: REMOVED_TINT,
},
codeLine: {
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: theme.lineHeight.diff,
color: theme.colors.foreground,
...(isWeb ? { whiteSpace: "pre", overflowWrap: "normal" } : null),
},
markerContext: {
color: theme.colors.foregroundMuted,
},
markerAdd: {
color: theme.colors.diffAddition,
},
markerRemove: {
color: theme.colors.diffDeletion,
},
}));
@@ -1,597 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Text, TextInput, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ChevronDown, Monitor, Moon, Sun } from "lucide-react-native";
import {
SYNTAX_THEME_OPTIONS,
type SyntaxThemeId,
type SyntaxThemeOption,
} from "@getpaseo/highlight";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SettingsSection } from "@/screens/settings/settings-section";
import {
MAX_CODE_FONT_SIZE,
MAX_UI_FONT_SIZE,
MIN_CODE_FONT_SIZE,
MIN_UI_FONT_SIZE,
parseClampedFontSize,
sanitizeFontFamily,
useAppSettings,
type AppSettings,
} from "@/hooks/use-settings";
import {
DEFAULT_MONO_FONT_STACK,
DEFAULT_UI_FONT_STACK,
ICON_SIZE,
THEME_SWATCHES,
type Theme,
} from "@/styles/theme";
import { isNative } from "@/constants/platform";
import { settingsStyles } from "@/styles/settings";
import { AppearancePreview } from "./appearance-preview";
// ---------------------------------------------------------------------------
// Theme-reactive leaf icons (withUnistyles + uniProps color mapping — no
// useUnistyles). Icon sizes read the static ICON_SIZE token; the appearance
// feature does not scale icons.
// ---------------------------------------------------------------------------
const ThemedSun = withUnistyles(Sun);
const ThemedMoon = withUnistyles(Moon);
const ThemedMonitor = withUnistyles(Monitor);
const ThemedChevronDown = withUnistyles(ChevronDown);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
// Stored value -> displayed label. `auto` reads as "System" for the app theme.
const THEME_LABELS: Record<AppSettings["theme"], string> = {
light: "Light",
dark: "Dark",
zinc: "Zinc",
midnight: "Midnight",
claude: "Claude",
ghostty: "Ghostty",
auto: "System",
};
const PRIMARY_THEMES: readonly AppSettings["theme"][] = ["light", "dark", "auto"];
const DARK_VARIANT_THEMES: readonly AppSettings["theme"][] = [
"zinc",
"midnight",
"claude",
"ghostty",
];
// Platform default stacks can be the bare native tokens ("normal"/"monospace");
// those read as a bug, so show a human label in the placeholder instead.
const BARE_DEFAULT_STACKS: ReadonlySet<string> = new Set(["normal", "monospace"]);
function resolveDefaultStackPlaceholder(stack: string): string {
return BARE_DEFAULT_STACKS.has(stack) ? "System default" : stack;
}
const UI_FONT_PLACEHOLDER = resolveDefaultStackPlaceholder(DEFAULT_UI_FONT_STACK);
const MONO_FONT_PLACEHOLDER = resolveDefaultStackPlaceholder(DEFAULT_MONO_FONT_STACK);
// Local size string (digits only) -> preview override number. Empty/invalid
// yields undefined so the preview falls back to the committed theme value.
function sizeDraftToOverride(value: string): number | undefined {
if (value.length === 0) return undefined;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
function dropdownTriggerStyle({ pressed }: PressableStateCallbackType) {
return [styles.trigger, pressed ? styles.triggerPressed : null];
}
// ---------------------------------------------------------------------------
// Theme picker
// ---------------------------------------------------------------------------
interface ThemeLeadingProps {
themeValue: AppSettings["theme"];
}
function ThemeLeading({ themeValue }: ThemeLeadingProps) {
switch (themeValue) {
case "light":
return <ThemedSun size={ICON_SIZE.md} uniProps={mutedColorMapping} />;
case "dark":
return <ThemedMoon size={ICON_SIZE.md} uniProps={mutedColorMapping} />;
case "auto":
return <ThemedMonitor size={ICON_SIZE.md} uniProps={mutedColorMapping} />;
default:
return <ThemeSwatch color={THEME_SWATCHES[themeValue]} />;
}
}
interface ThemeSwatchProps {
color: string;
}
function ThemeSwatch({ color }: ThemeSwatchProps) {
const swatchStyle = useMemo(() => [styles.swatch, { backgroundColor: color }], [color]);
return <View style={swatchStyle} />;
}
interface ThemeMenuItemProps {
themeValue: AppSettings["theme"];
selected: boolean;
onChange: (theme: AppSettings["theme"]) => void;
}
function ThemeMenuItem({ themeValue, selected, onChange }: ThemeMenuItemProps) {
const handleSelect = useCallback(() => {
onChange(themeValue);
}, [onChange, themeValue]);
const leading = useMemo(() => <ThemeLeading themeValue={themeValue} />, [themeValue]);
return (
<DropdownMenuItem selected={selected} onSelect={handleSelect} leading={leading}>
{THEME_LABELS[themeValue]}
</DropdownMenuItem>
);
}
interface ThemeRowProps {
value: AppSettings["theme"];
onChange: (theme: AppSettings["theme"]) => void;
}
function ThemeRow({ value, onChange }: ThemeRowProps) {
return (
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Theme</Text>
</View>
<DropdownMenu>
<DropdownMenuTrigger
style={dropdownTriggerStyle}
accessibilityLabel={`Theme: ${THEME_LABELS[value]}`}
>
<ThemeLeading themeValue={value} />
<Text style={styles.triggerText}>{THEME_LABELS[value]}</Text>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
{PRIMARY_THEMES.map((themeValue) => (
<ThemeMenuItem
key={themeValue}
themeValue={themeValue}
selected={value === themeValue}
onChange={onChange}
/>
))}
<DropdownMenuSeparator />
{DARK_VARIANT_THEMES.map((themeValue) => (
<ThemeMenuItem
key={themeValue}
themeValue={themeValue}
selected={value === themeValue}
onChange={onChange}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
);
}
// ---------------------------------------------------------------------------
// Fonts: family text fields + numeric size fields (commit on blur/submit)
// ---------------------------------------------------------------------------
interface FontFamilyRowProps {
title: string;
hint: string;
accessibilityLabel: string;
placeholder: string;
value: string;
draft: string;
withBorder: boolean;
onChangeDraft: (value: string) => void;
onCommit: (value: string) => void;
}
function FontFamilyRow({
title,
hint,
accessibilityLabel,
placeholder,
value,
draft,
withBorder,
onChangeDraft,
onCommit,
}: FontFamilyRowProps) {
const handleCommit = useCallback(() => {
onCommit(draft);
}, [draft, onCommit]);
// Resync from the committed value when it changes elsewhere.
useEffect(() => {
onChangeDraft(value);
// Only resync on external value changes, not on local keystrokes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
return (
<View style={withBorder ? styles.rowWithBorder : settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{title}</Text>
<Text style={settingsStyles.rowHint}>{hint}</Text>
</View>
<TextInput
value={draft}
onChangeText={onChangeDraft}
onBlur={handleCommit}
onSubmitEditing={handleCommit}
placeholder={placeholder}
placeholderTextColor={styles.placeholderColor.color}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
style={styles.fontFamilyInput}
accessibilityLabel={accessibilityLabel}
/>
</View>
);
}
interface FontSizeRowProps {
title: string;
accessibilityLabel: string;
draft: string;
withBorder?: boolean;
onChangeDraft: (value: string) => void;
onCommit: () => void;
}
function FontSizeRow({
title,
accessibilityLabel,
draft,
withBorder = true,
onChangeDraft,
onCommit,
}: FontSizeRowProps) {
return (
<View style={withBorder ? styles.rowWithBorder : settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{title}</Text>
</View>
<View style={styles.sizeField}>
<TextInput
value={draft}
onChangeText={onChangeDraft}
onBlur={onCommit}
onSubmitEditing={onCommit}
keyboardType="number-pad"
inputMode="numeric"
selectTextOnFocus
style={styles.sizeInput}
accessibilityLabel={accessibilityLabel}
/>
<Text style={styles.unit}>px</Text>
</View>
</View>
);
}
// ---------------------------------------------------------------------------
// Syntax highlight theme picker (commits immediately)
// ---------------------------------------------------------------------------
function syntaxLabelForId(id: SyntaxThemeId): string {
const option = SYNTAX_THEME_OPTIONS.find((entry) => entry.id === id);
return option ? option.label : id;
}
interface SyntaxMenuItemProps {
option: SyntaxThemeOption;
selected: boolean;
onChange: (id: SyntaxThemeId) => void;
}
function SyntaxMenuItem({ option, selected, onChange }: SyntaxMenuItemProps) {
const handleSelect = useCallback(() => {
onChange(option.id);
}, [onChange, option.id]);
return (
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
{option.label}
</DropdownMenuItem>
);
}
interface SyntaxRowProps {
value: SyntaxThemeId;
onChange: (id: SyntaxThemeId) => void;
}
function SyntaxRow({ value, onChange }: SyntaxRowProps) {
return (
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Highlight theme</Text>
<Text style={settingsStyles.rowHint}>Colors for code, independent of the app theme</Text>
</View>
<DropdownMenu>
<DropdownMenuTrigger
style={dropdownTriggerStyle}
accessibilityLabel={`Highlight theme: ${syntaxLabelForId(value)}`}
>
<Text style={styles.triggerText}>{syntaxLabelForId(value)}</Text>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" width={200}>
{SYNTAX_THEME_OPTIONS.map((option) => (
<SyntaxMenuItem
key={option.id}
option={option}
selected={value === option.id}
onChange={onChange}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export function AppearanceSection() {
const { settings, updateSettings } = useAppSettings();
const showFontFamilyRows = !isNative;
const [uiFontDraft, setUiFontDraft] = useState(settings.uiFontFamily);
const [monoFontDraft, setMonoFontDraft] = useState(settings.monoFontFamily);
const [uiSizeDraft, setUiSizeDraft] = useState(String(settings.uiFontSize));
const [codeSizeDraft, setCodeSizeDraft] = useState(String(settings.codeFontSize));
// Resync numeric drafts when the committed value changes elsewhere.
useEffect(() => {
setUiSizeDraft(String(settings.uiFontSize));
}, [settings.uiFontSize]);
useEffect(() => {
setCodeSizeDraft(String(settings.codeFontSize));
}, [settings.codeFontSize]);
const handleThemeChange = useCallback(
(theme: AppSettings["theme"]) => {
void updateSettings({ theme });
},
[updateSettings],
);
const handleSyntaxThemeChange = useCallback(
(syntaxTheme: SyntaxThemeId) => {
void updateSettings({ syntaxTheme });
},
[updateSettings],
);
const commitUiFontFamily = useCallback(
(value: string) => {
const sanitized = sanitizeFontFamily(value);
if (sanitized === null) {
setUiFontDraft(settings.uiFontFamily);
return;
}
setUiFontDraft(sanitized);
if (sanitized !== settings.uiFontFamily) {
void updateSettings({ uiFontFamily: sanitized });
}
},
[settings.uiFontFamily, updateSettings],
);
const commitMonoFontFamily = useCallback(
(value: string) => {
const sanitized = sanitizeFontFamily(value);
if (sanitized === null) {
setMonoFontDraft(settings.monoFontFamily);
return;
}
setMonoFontDraft(sanitized);
if (sanitized !== settings.monoFontFamily) {
void updateSettings({ monoFontFamily: sanitized });
}
},
[settings.monoFontFamily, updateSettings],
);
const handleUiSizeChange = useCallback((value: string) => {
setUiSizeDraft(value.replace(/[^\d]/g, ""));
}, []);
const handleCodeSizeChange = useCallback((value: string) => {
setCodeSizeDraft(value.replace(/[^\d]/g, ""));
}, []);
const commitUiSize = useCallback(() => {
const parsed = parseClampedFontSize(uiSizeDraft, {
min: MIN_UI_FONT_SIZE,
max: MAX_UI_FONT_SIZE,
});
const next = parsed ?? settings.uiFontSize;
setUiSizeDraft(String(next));
if (next !== settings.uiFontSize) {
void updateSettings({ uiFontSize: next });
}
}, [settings.uiFontSize, uiSizeDraft, updateSettings]);
const commitCodeSize = useCallback(() => {
const parsed = parseClampedFontSize(codeSizeDraft, {
min: MIN_CODE_FONT_SIZE,
max: MAX_CODE_FONT_SIZE,
});
const next = parsed ?? settings.codeFontSize;
setCodeSizeDraft(String(next));
if (next !== settings.codeFontSize) {
void updateSettings({ codeFontSize: next });
}
}, [codeSizeDraft, settings.codeFontSize, updateSettings]);
// Live-while-typing: the in-progress drafts drive the preview without
// committing to the global theme. Empty/invalid fields fall back to the
// theme value inside the preview.
const previewOverrides = useMemo(
() => ({
monoFontFamily: monoFontDraft,
codeFontSize: sizeDraftToOverride(codeSizeDraft),
}),
[codeSizeDraft, monoFontDraft],
);
return (
<View>
<SettingsSection title="Theme">
<View style={settingsStyles.card}>
<ThemeRow value={settings.theme} onChange={handleThemeChange} />
</View>
</SettingsSection>
<SettingsSection title="Fonts">
<View style={settingsStyles.card}>
{showFontFamilyRows ? (
<FontFamilyRow
title="Interface font"
hint="Used across the app. Leave empty for the system default"
accessibilityLabel="Interface font family"
placeholder={UI_FONT_PLACEHOLDER}
value={settings.uiFontFamily}
draft={uiFontDraft}
withBorder={false}
onChangeDraft={setUiFontDraft}
onCommit={commitUiFontFamily}
/>
) : null}
<FontSizeRow
title="Interface size"
accessibilityLabel="Interface font size"
draft={uiSizeDraft}
withBorder={showFontFamilyRows}
onChangeDraft={handleUiSizeChange}
onCommit={commitUiSize}
/>
{showFontFamilyRows ? (
<FontFamilyRow
title="Code font"
hint="Used in code, diffs, and the terminal output. Leave empty for the system default"
accessibilityLabel="Code font family"
placeholder={MONO_FONT_PLACEHOLDER}
value={settings.monoFontFamily}
draft={monoFontDraft}
withBorder
onChangeDraft={setMonoFontDraft}
onCommit={commitMonoFontFamily}
/>
) : null}
<FontSizeRow
title="Code size"
accessibilityLabel="Code font size"
draft={codeSizeDraft}
onChangeDraft={handleCodeSizeChange}
onCommit={commitCodeSize}
/>
</View>
</SettingsSection>
<SettingsSection title="Syntax">
<View style={settingsStyles.card}>
<SyntaxRow value={settings.syntaxTheme} onChange={handleSyntaxThemeChange} />
</View>
<View style={styles.preview}>
<AppearancePreview overrides={previewOverrides} />
</View>
</SettingsSection>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
preview: {
marginTop: theme.spacing[4],
},
rowWithBorder: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[4],
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
},
trigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
triggerPressed: {
opacity: 0.85,
},
triggerText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
swatch: {
width: ICON_SIZE.md,
height: ICON_SIZE.md,
borderRadius: ICON_SIZE.md / 2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
fontFamilyInput: {
flexGrow: 1,
flexShrink: 1,
maxWidth: 280,
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
textAlign: "left",
},
sizeField: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
sizeInput: {
width: 64,
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface2,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
textAlign: "right",
},
unit: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
placeholderColor: {
color: theme.colors.foregroundMuted,
},
}));
@@ -1,176 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { darkHighlightColors, resolveSyntaxColors } from "@getpaseo/highlight";
import { DEFAULT_UI_FONT_STACK } from "@/styles/theme";
import { applyAppearance, type AppearanceInput } from "./apply-appearance";
// Override the global react-native-unistyles mock (vitest.setup.ts) so that
// UnistylesRuntime.updateTheme is a spy that records (themeName, updater) calls.
const { updateTheme } = vi.hoisted(() => ({ updateTheme: vi.fn() }));
vi.mock("react-native-unistyles", () => ({ UnistylesRuntime: { updateTheme } }));
// The six registered Unistyles theme keys, in the order applyAppearance patches them.
const ALL_THEME_KEYS = [
"light",
"dark",
"darkZinc",
"darkMidnight",
"darkClaude",
"darkGhostty",
] as const;
// The signature of the updater passed to UnistylesRuntime.updateTheme.
type ThemeUpdater = (theme: FakeTheme) => FakeTheme;
// The subset of the theme shape the updater reads / spreads. The real Theme type
// is a frozen `as const` literal; the updater only touches these fields. Casting a
// fake of this shape through `unknown` to ThemeUpdater's param is test-only.
interface FakeTheme {
colorScheme: "light" | "dark";
fontFamily: { ui: string; mono: string };
fontSize: {
xs: number;
code: number;
sm: number;
base: number;
lg: number;
xl: number;
"2xl": number;
"3xl": number;
"4xl": number;
};
lineHeight: { diff: number };
colors: { foreground: string; syntax: Record<string, string> };
}
function makeFakeTheme(): FakeTheme {
return {
colorScheme: "dark",
fontFamily: { ui: "seed-ui-stack", mono: "seed-mono-stack" },
fontSize: {
xs: 12,
code: 12,
sm: 14,
base: 16,
lg: 18,
xl: 20,
"2xl": 22,
"3xl": 26,
"4xl": 34,
},
lineHeight: { diff: 22 },
colors: { foreground: "#fff", syntax: {} },
};
}
function makeInput(overrides: Partial<AppearanceInput> = {}): AppearanceInput {
return {
uiFontFamily: "",
monoFontFamily: "",
uiFontSize: 16,
codeFontSize: 12,
syntaxTheme: "one",
...overrides,
};
}
// Run a single captured updater (default the first) against a fresh fake theme.
function runCapturedUpdater(call = 0): FakeTheme {
const updater = updateTheme.mock.calls[call]?.[1] as unknown as ThemeUpdater;
return updater(makeFakeTheme());
}
describe("applyAppearance", () => {
beforeEach(() => {
updateTheme.mockClear();
});
it("patches every registered Unistyles theme exactly once", () => {
applyAppearance(makeInput());
expect(updateTheme).toHaveBeenCalledTimes(6);
expect(updateTheme.mock.calls.map((call) => call[0])).toEqual([...ALL_THEME_KEYS]);
});
it("resolves an empty UI font family to the default stack", () => {
applyAppearance(makeInput({ uiFontFamily: "" }));
expect(runCapturedUpdater().fontFamily.ui).toBe(DEFAULT_UI_FONT_STACK);
});
it("passes a non-empty UI font family through trimmed", () => {
applyAppearance(makeInput({ uiFontFamily: " Menlo " }));
expect(runCapturedUpdater().fontFamily.ui).toBe("Menlo");
});
it("scales the whole UI ramp proportionally while preserving ratios", () => {
applyAppearance(makeInput({ uiFontSize: 14 }));
const { fontSize } = runCapturedUpdater();
// r = 14 / 16 = 0.875
expect(fontSize.base).toBe(14); // round(16 * 0.875)
expect(fontSize.lg).toBe(16); // round(18 * 0.875) = round(15.75)
expect(fontSize.xs).toBe(11); // round(12 * 0.875) = round(10.5)
expect(fontSize["4xl"]).toBe(30); // round(34 * 0.875) = round(29.75)
});
it("derives the UI ramp from the canonical sizes, not the live theme (no compounding)", () => {
applyAppearance(makeInput({ uiFontSize: 14 }));
// Simulate a theme whose fontSize was already scaled by a prior apply; the
// updater must ignore it and rebuild from the authored FONT_SIZE ramp.
const updater = updateTheme.mock.calls[0]?.[1] as unknown as ThemeUpdater;
const alreadyScaled = makeFakeTheme();
alreadyScaled.fontSize = {
xs: 4,
code: 4,
sm: 4,
base: 4,
lg: 4,
xl: 4,
"2xl": 4,
"3xl": 4,
"4xl": 4,
};
const { fontSize } = updater(alreadyScaled);
expect(fontSize.base).toBe(14); // not 4 * 0.875 — rebuilt from FONT_SIZE
expect(fontSize.lg).toBe(16);
});
it("leaves the UI ramp at authored sizes when only the code size changes", () => {
applyAppearance(makeInput({ uiFontSize: 16, codeFontSize: 10 }));
const { fontSize } = runCapturedUpdater();
expect(fontSize.base).toBe(16);
expect(fontSize.sm).toBe(14);
expect(fontSize.code).toBe(10);
});
it("sets fontSize.code to codeFontSize regardless of the UI font size", () => {
applyAppearance(makeInput({ uiFontSize: 14, codeFontSize: 18 }));
expect(runCapturedUpdater().fontSize.code).toBe(18);
});
it("couples lineHeight.diff to the code font size", () => {
applyAppearance(makeInput({ codeFontSize: 18 }));
expect(runCapturedUpdater().lineHeight.diff).toBe(Math.round(18 * 1.5)); // 27
});
it("swaps colors.syntax to the resolved palette for the named theme", () => {
applyAppearance(makeInput({ syntaxTheme: "dracula" }));
const { colors } = runCapturedUpdater();
expect(colors.syntax).toEqual(resolveSyntaxColors("dracula", "dark"));
});
it("resolves a syntax theme using the theme's own color scheme", () => {
applyAppearance(makeInput({ syntaxTheme: "github" }));
// makeFakeTheme().colorScheme === "dark" -> github resolves to the dark palette.
expect(runCapturedUpdater().colors.syntax).toEqual(darkHighlightColors);
expect(runCapturedUpdater().colors.syntax).toEqual(resolveSyntaxColors("github", "dark"));
});
});
@@ -1,105 +0,0 @@
import { UnistylesRuntime } from "react-native-unistyles";
import { resolveSyntaxColors, type SyntaxThemeId } from "@getpaseo/highlight";
import {
DEFAULT_UI_FONT_STACK,
DEFAULT_MONO_FONT_STACK,
FONT_SIZE,
type Theme,
} from "@/styles/theme";
import { applyRootUiFont } from "./apply-root-font";
// All six registered Unistyles keys — pinned literal (greppable, type-checked).
// The `as const` element types are exactly `keyof UnistylesThemes`, so each key
// is assignable to `UnistylesRuntime.updateTheme`'s first argument with no cast.
const ALL_THEME_KEYS = [
"light",
"dark",
"darkZinc",
"darkMidnight",
"darkClaude",
"darkGhostty",
] as const;
// The UI font size at which the FONT_SIZE ramp is authored (1.0 scale factor).
const BASE_UI_REFERENCE = FONT_SIZE.base; // 16
export interface AppearanceInput {
uiFontFamily: string; // "" -> default stack
monoFontFamily: string; // "" -> default stack
uiFontSize: number; // already clamped
codeFontSize: number; // already clamped
syntaxTheme: SyntaxThemeId;
}
/**
* Build the font-size ramp from the canonical `FONT_SIZE` ramp, scaled
* proportionally by `uiSize / 16` so the type hierarchy is preserved at non-default
* sizes. Deriving from the authored ramp — NOT the live (possibly already-scaled)
* theme — makes `applyAppearance` idempotent: repeated applies never compound, and a
* code-size change (uiSize unchanged) leaves the UI ramp at its authored values.
* `code` is set absolutely to `codeSize`, never scaled by the UI factor — a separate
* control on a separate semantic axis (mono/diff text).
*/
function scaleFontSize(uiSize: number, codeSize: number): Theme["fontSize"] {
const r = uiSize / BASE_UI_REFERENCE;
return {
xs: Math.round(FONT_SIZE.xs * r),
sm: Math.round(FONT_SIZE.sm * r),
base: Math.round(FONT_SIZE.base * r),
lg: Math.round(FONT_SIZE.lg * r),
xl: Math.round(FONT_SIZE.xl * r),
"2xl": Math.round(FONT_SIZE["2xl"] * r),
"3xl": Math.round(FONT_SIZE["3xl"] * r),
"4xl": Math.round(FONT_SIZE["4xl"] * r),
code: codeSize, // absolute, NOT scaled
};
}
/**
* Patch every registered Unistyles theme with the user's appearance choices.
* All six keys are patched because the active theme can change and adaptive mode
* can flip light/dark — patching all keys keeps the active key always current and
* makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant.
*/
export function applyAppearance(input: AppearanceInput): void {
const ui = input.uiFontFamily.trim() || DEFAULT_UI_FONT_STACK;
const mono = input.monoFontFamily.trim() || DEFAULT_MONO_FONT_STACK;
const diffLineHeight = Math.round(input.codeFontSize * 1.5); // couple to code size
for (const key of ALL_THEME_KEYS) {
// Spread `...t` first — `updateTheme` replaces the stored theme, it does not
// merge; an omitted key would be dropped. `syntax` follows the theme's own
// scheme for `auto`; named palettes ignore it. `colors.base`/plain text stays
// `theme.colors.foreground` (owned by `syntaxTokenStyles.base`, not patched).
//
// Narrow on the `colorScheme` discriminant before spreading: the updater must
// return the theme union, and a spread of the union widens `colorScheme` to
// `"light" | "dark"`, assignable to neither concrete member. Each branch spreads
// a single narrowed theme type.
UnistylesRuntime.updateTheme(key, (t) => {
const fontFamily = { ui, mono };
const fontSize = scaleFontSize(input.uiFontSize, input.codeFontSize);
const lineHeight = { ...t.lineHeight, diff: diffLineHeight };
if (t.colorScheme === "light") {
return {
...t,
fontFamily,
fontSize,
lineHeight,
colors: { ...t.colors, syntax: resolveSyntaxColors(input.syntaxTheme, t.colorScheme) },
};
}
return {
...t,
fontFamily,
fontSize,
lineHeight,
colors: { ...t.colors, syntax: resolveSyntaxColors(input.syntaxTheme, t.colorScheme) },
};
});
}
// Web: apply the UI font app-wide (RN-web stamps a default font on every text
// element, so it can't be done through the theme alone). No-op on native.
applyRootUiFont(ui);
}
@@ -1,4 +0,0 @@
// Native (and default) no-op: React Native has no global font cascade, so the
// interface font applies only where components read theme.fontFamily.ui. The web
// build (apply-root-font.web.ts) overrides this to apply it app-wide.
export function applyRootUiFont(_uiFontStack: string): void {}
@@ -1,33 +0,0 @@
// Apply the interface (UI) font app-wide on web.
//
// react-native-web stamps a hardcoded default font onto every text element, so a
// plain `body { font-family }` never cascades in — the element already has its own
// font. Instead we inject ONE rule that points all text at a CSS variable and set
// that variable live. The selector is high-specificity (1,2,0) so it deterministically
// beats both RN-web's base font and Unistyles' generated classes (0,1,0) — no reliance
// on stylesheet order. Code/diff/terminal surfaces carry `data-pmono` (and have their
// subtree excluded via `:not([data-pmono] *)`) so they keep their monospace font.
const STYLE_ID = "paseo-ui-font";
const RULE = "#root *:not([data-pmono]):not([data-pmono] *){font-family:var(--paseo-ui-font);}";
export function applyRootUiFont(uiFontStack: string): void {
if (typeof document === "undefined") return;
// Strip anything that could break out of the CSS value; commas/quotes/spaces in a
// font stack are fine.
const value = uiFontStack
.replace(/[<>{}();]/g, "")
.replace(/[\r\n]/g, " ")
.trim();
if (value.length === 0) return;
document.documentElement.style.setProperty("--paseo-ui-font", value);
// The rule itself is static (references the variable); inject it once.
let style = document.getElementById(STYLE_ID);
if (!style) {
style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = RULE;
document.head.appendChild(style);
}
}
@@ -1,21 +0,0 @@
// A small, syntax-rich TypeScript change rendered side-by-side in the appearance
// preview. BEFORE and AFTER are aligned 1:1 (5 lines each); only the indices in
// CHANGED_LINE_INDICES differ, so the diff tints land on matching rows.
export const PREVIEW_BEFORE: string[] = [
"// Format a price for display",
"export function formatPrice(cents: number) {",
" const amount = cents / 100;",
' return "$" + amount;',
"}",
];
export const PREVIEW_AFTER: string[] = [
"// Format a price for display",
"export function formatPrice(cents: number): string {",
" const amount = cents / 100;",
" return `$${amount.toFixed(2)}`;",
"}",
];
export const CHANGED_LINE_INDICES: ReadonlySet<number> = new Set([1, 3]);
@@ -16,11 +16,11 @@ import { BookOpen, Copy, RotateCw, TriangleAlert } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { Button } from "@/components/ui/button";
import { Fonts } from "@/constants/theme";
import { getDesktopDaemonLogs, type DesktopDaemonLogs } from "@/desktop/daemon/desktop-daemon";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { isNative, isWeb } from "@/constants/platform";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
interface StartupSplashScreenProps {
bootstrapState?: {
@@ -236,7 +236,7 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.destructive,
fontSize: theme.fontSize.code,
lineHeight: 20,
fontFamily: theme.fontFamily.mono,
fontFamily: Fonts.mono,
},
logsMeta: {
color: theme.colors.foregroundMuted,
@@ -257,7 +257,7 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[4],
},
logsText: {
fontFamily: theme.fontFamily.mono,
fontFamily: Fonts.mono,
fontSize: theme.fontSize.code,
color: theme.colors.foreground,
lineHeight: 18,
@@ -417,9 +417,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
GitHub and include the logs below.
</Text>
<Text dataSet={CODE_SURFACE_DATASET} style={styles.errorMessage}>
{bootstrapState.splashError}
</Text>
<Text style={styles.errorMessage}>{bootstrapState.splashError}</Text>
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
@@ -429,7 +427,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
contentContainerStyle={styles.logsContent}
showsVerticalScrollIndicator
>
<Text dataSet={CODE_SURFACE_DATASET} selectable style={styles.logsText}>
<Text selectable style={styles.logsText}>
{logsText}
</Text>
</ScrollView>
@@ -62,7 +62,6 @@ import {
} from "@/screens/workspace/workspace-tab-menu";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { Theme } from "@/styles/theme";
import { RenderProfile } from "@/utils/render-profiler";
const DROPDOWN_WIDTH = 220;
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
@@ -149,6 +148,7 @@ interface WorkspaceDesktopTabsRowProps {
tabs: WorkspaceDesktopTabRowItem[];
normalizedServerId: string;
normalizedWorkspaceId: string;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
onNavigateTab: (tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
@@ -255,6 +255,7 @@ function TabChip({
presentation,
tooltipLabel,
resolvedTab,
setHoveredTabKey,
setHoveredCloseTabKey,
onNavigateTab,
onCloseTab,
@@ -272,6 +273,7 @@ function TabChip({
presentation: WorkspaceTabPresentation;
tooltipLabel: string;
resolvedTab: WorkspaceDesktopTabActions;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
onNavigateTab: (tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
@@ -309,11 +311,13 @@ function TabChip({
const handleTabHoverIn = useCallback(() => {
setHovered(true);
}, []);
setHoveredTabKey(tab.key);
}, [setHoveredTabKey, tab.key]);
const handleTabHoverOut = useCallback(() => {
setHovered(false);
}, []);
setHoveredTabKey((current) => (current === tab.key ? null : current));
}, [setHoveredTabKey, tab.key]);
const handleNavigateTab = useCallback(() => {
onNavigateTab(tab.tabId);
@@ -324,12 +328,14 @@ function TabChip({
}, []);
const handleCloseButtonHoverIn = useCallback(() => {
setHoveredTabKey(tab.key);
setHoveredCloseTabKey(tab.key);
}, [setHoveredCloseTabKey, tab.key]);
}, [setHoveredTabKey, setHoveredCloseTabKey, tab.key]);
const handleCloseButtonHoverOut = useCallback(() => {
setHoveredTabKey((current) => (current === tab.key ? null : current));
setHoveredCloseTabKey((current) => (current === tab.key ? null : current));
}, [setHoveredCloseTabKey, tab.key]);
}, [setHoveredTabKey, setHoveredCloseTabKey, tab.key]);
const handleCloseButtonPress = useCallback(
(event: { stopPropagation?: () => void }) => {
@@ -460,6 +466,7 @@ export function WorkspaceDesktopTabsRow({
tabs,
normalizedServerId,
normalizedWorkspaceId,
setHoveredTabKey,
setHoveredCloseTabKey,
onNavigateTab,
onCloseTab,
@@ -606,6 +613,7 @@ export function WorkspaceDesktopTabsRow({
resolvedTabWidth={resolvedTabWidth}
showLabel={showLabel}
showCloseButton={shouldShowCloseButton}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
@@ -632,6 +640,7 @@ export function WorkspaceDesktopTabsRow({
onReloadAgent,
onRenameTab,
setHoveredCloseTabKey,
setHoveredTabKey,
tabDropPreviewIndex,
tabs.length,
],
@@ -647,7 +656,7 @@ export function WorkspaceDesktopTabsRow({
[layout.requiresHorizontalScrollFallback],
);
const row = (
return (
<View
style={styles.tabsContainer}
testID="workspace-tabs-row"
@@ -778,9 +787,8 @@ export function WorkspaceDesktopTabsRow({
</View>
</View>
);
return <RenderProfile id="WorkspaceDesktopTabsRow">{row}</RenderProfile>;
}
function ResolvedDesktopTabChip({
item,
isFocused,
@@ -799,6 +807,7 @@ function ResolvedDesktopTabChip({
resolvedTabWidth,
showLabel,
showCloseButton,
setHoveredTabKey,
setHoveredCloseTabKey,
onNavigateTab,
onCloseTab,
@@ -823,6 +832,7 @@ function ResolvedDesktopTabChip({
resolvedTabWidth: number;
showLabel: boolean;
showCloseButton: boolean;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
onNavigateTab: (tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
@@ -886,6 +896,7 @@ function ResolvedDesktopTabChip({
presentation={presentation}
tooltipLabel={tooltipLabel}
resolvedTab={resolvedTab}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
@@ -113,6 +113,7 @@ describe("WorkspacePaneContent", () => {
isInteractive: false,
focusPane: expect.any(Function),
});
expect(snapshots[0]?.paneContextValue.paneInstanceId).toBeNull();
expect(snapshots[1]?.focus).toEqual({
isWorkspaceFocused: true,
isPaneFocused: true,
@@ -1,4 +1,4 @@
import React, { useMemo, type ComponentType } from "react";
import React, { useEffect, useMemo, type ComponentType } from "react";
import invariant from "tiny-invariant";
import {
createPaneFocusContextValue,
@@ -6,12 +6,15 @@ import {
PaneProvider,
type PaneContextValue,
} from "@/panels/pane-context";
import { useStableEvent } from "@/hooks/use-stable-event";
import { getPanelRegistration } from "@/panels/panel-registry";
import { ensurePanelsRegistered } from "@/panels/register-panels";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { RenderProfile } from "@/utils/render-profiler";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
import {
clearActivePaneFindPaneId,
createPaneFindPaneId,
setActivePaneFindPaneId,
} from "@/panels/pane-find-registry";
export interface WorkspacePaneContentModel {
key: string;
@@ -21,6 +24,7 @@ export interface WorkspacePaneContentModel {
export interface BuildWorkspacePaneContentModelInput {
tab: WorkspaceTabDescriptor;
paneId?: string | null;
normalizedServerId: string;
normalizedWorkspaceId: string;
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
@@ -32,6 +36,7 @@ export interface BuildWorkspacePaneContentModelInput {
export function buildWorkspacePaneContentModel({
tab,
paneId,
normalizedServerId,
normalizedWorkspaceId,
onOpenTab,
@@ -43,12 +48,20 @@ export function buildWorkspacePaneContentModel({
ensurePanelsRegistered();
const registration = getPanelRegistration(tab.kind);
invariant(registration, `No panel registration for kind: ${tab.kind}`);
const paneInstanceId = paneId
? createPaneFindPaneId({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
paneId,
})
: null;
return {
key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`,
Component: registration.component,
paneContextValue: {
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
paneInstanceId,
tabId: tab.tabId,
target: tab.target,
openTab: onOpenTab,
@@ -74,35 +87,6 @@ export function WorkspacePaneContent({
onFocusPane,
}: WorkspacePaneContentProps) {
const { Component, key, paneContextValue } = content;
const openTab = useStableEvent(paneContextValue.openTab);
const closeCurrentTab = useStableEvent(paneContextValue.closeCurrentTab);
const retargetCurrentTab = useStableEvent(paneContextValue.retargetCurrentTab);
const openFileInWorkspace = useStableEvent(paneContextValue.openFileInWorkspace);
const openImportSheet = useStableEvent(paneContextValue.openImportSheet);
const stablePaneContextValue = useMemo(
() => ({
serverId: paneContextValue.serverId,
workspaceId: paneContextValue.workspaceId,
tabId: paneContextValue.tabId,
target: paneContextValue.target,
openTab,
closeCurrentTab,
retargetCurrentTab,
openFileInWorkspace,
openImportSheet,
}),
[
closeCurrentTab,
openFileInWorkspace,
openImportSheet,
openTab,
paneContextValue.serverId,
paneContextValue.tabId,
paneContextValue.target,
paneContextValue.workspaceId,
retargetCurrentTab,
],
);
const paneFocusValue = useMemo(
() =>
createPaneFocusContextValue({
@@ -112,16 +96,22 @@ export function WorkspacePaneContent({
}),
[isPaneFocused, isWorkspaceFocused, onFocusPane],
);
useEffect(() => {
if (!paneContextValue.paneInstanceId || !isWorkspaceFocused || !isPaneFocused) {
return;
}
const paneInstanceId = paneContextValue.paneInstanceId;
setActivePaneFindPaneId(paneInstanceId);
return () => {
clearActivePaneFindPaneId(paneInstanceId);
};
}, [isPaneFocused, isWorkspaceFocused, paneContextValue.paneInstanceId]);
return (
<RenderProfile
id={`WorkspacePaneContent:${paneContextValue.target.kind}:${paneContextValue.tabId}`}
>
<PaneProvider value={stablePaneContextValue}>
<PaneFocusProvider value={paneFocusValue}>
<Component key={key} />
</PaneFocusProvider>
</PaneProvider>
</RenderProfile>
<PaneProvider value={paneContextValue}>
<PaneFocusProvider value={paneFocusValue}>
<Component key={key} />
</PaneFocusProvider>
</PaneProvider>
);
}
@@ -177,7 +177,6 @@ import {
type WorkspaceFileLocation,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
import { RenderProfile } from "@/utils/render-profiler";
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels";
@@ -735,43 +734,16 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
: styles.mobileMountedTabSlotHidden;
return (
<RenderProfile id={`MobileMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
<View style={slotStyle} pointerEvents={isVisible ? "auto" : "none"}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View>
</RenderProfile>
<View style={slotStyle} pointerEvents={isVisible ? "auto" : "none"}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View>
);
});
interface MobileExplorerOpenGestureSurfaceProps {
children: ReactNode;
onOpenExplorer: () => void;
}
function MobileExplorerOpenGestureSurface({
children,
onOpenExplorer,
}: MobileExplorerOpenGestureSurfaceProps) {
const canOpenExplorerFromAgentView = usePanelStore(
(state) =>
state.mobileView === "agent" && !selectIsFileExplorerOpen(state, { isCompact: true }),
);
const explorerOpenGesture = useExplorerOpenGesture({
enabled: canOpenExplorerFromAgentView,
onOpen: onOpenExplorer,
});
return (
<GestureDetector gesture={explorerOpenGesture} touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}>
<View style={styles.content}>{children}</View>
</GestureDetector>
);
}
function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
const cacheRef = useRef(new Map<string, WorkspaceTabDescriptor>());
const tabDescriptorMap = useMemo(() => {
@@ -798,11 +770,7 @@ function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
return tabDescriptorMap;
}
export const WorkspaceScreen = memo(function WorkspaceScreen({
serverId,
workspaceId,
isRouteFocused,
}: WorkspaceScreenProps) {
export function WorkspaceScreen({ serverId, workspaceId, isRouteFocused }: WorkspaceScreenProps) {
const navigationFocused = useIsFocused();
const effectiveRouteFocused = isRouteFocused ?? navigationFocused;
@@ -815,7 +783,7 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
/>
</ExplorerSidebarAnimationProvider>
);
});
}
interface UseCloseTabsResult {
closingTabIds: Set<string>;
@@ -1689,6 +1657,10 @@ function WorkspaceScreenContent({
const isExplorerOpen = usePanelStore((state) =>
selectIsFileExplorerOpen(state, { isCompact: isMobile }),
);
const canOpenExplorerFromAgentView = usePanelStore(
(state) =>
state.mobileView === "agent" && !selectIsFileExplorerOpen(state, { isCompact: true }),
);
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const toggleFileExplorerForCheckout = usePanelStore(
(state) => state.toggleFileExplorerForCheckout,
@@ -1740,6 +1712,11 @@ function WorkspaceScreenContent({
[isExplorerOpen],
);
const explorerOpenGesture = useExplorerOpenGesture({
enabled: isMobile && canOpenExplorerFromAgentView,
onOpen: openExplorerForWorkspace,
});
useEffect(() => {
if (!isRouteFocused || isWeb || !isExplorerOpen) {
return;
@@ -2201,31 +2178,7 @@ function WorkspaceScreenContent({
],
);
const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({
request,
paneId,
parentTabId,
focusPaneBeforeOpen,
}: {
request: WorkspaceFileOpenRequest;
paneId?: string | null;
parentTabId: string;
focusPaneBeforeOpen?: boolean;
}) {
if (focusPaneBeforeOpen && paneId && persistenceKey) {
focusWorkspacePane(persistenceKey, paneId);
}
if (request.disposition === "side") {
handleOpenFileFromChatInSidePane({
location: request.location,
sourcePaneId: paneId ?? undefined,
parentTabId,
});
return;
}
handleOpenFileFromChat(request.location, { parentTabId });
});
const [_hoveredTabKey, setHoveredTabKey] = useState<string | null>(null);
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(null);
const { handleRenameTab, renamingTab, handleRenameModalSubmit, handleRenameModalClose } =
useWorkspaceTabRename({
@@ -2347,6 +2300,7 @@ function WorkspaceScreenContent({
}
removeTerminalFromCache(terminalId);
setHoveredTabKey((current) => (current === tabId ? null : current));
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
if (persistenceKey) {
closeWorkspaceTabWithCleanup({
@@ -2395,6 +2349,7 @@ function WorkspaceScreenContent({
}
}
setHoveredTabKey((current) => (current === tabId ? null : current));
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
if (persistenceKey) {
closeWorkspaceTabWithCleanup({
@@ -2419,6 +2374,7 @@ function WorkspaceScreenContent({
tabId: string;
target?: WorkspaceTabTarget | null;
}) {
setHoveredTabKey((current) => (current === input.tabId ? null : current));
setHoveredCloseTabKey((current) => (current === input.tabId ? null : current));
if (persistenceKey) {
closeWorkspaceTabWithCleanup({ tabId: input.tabId, target: input.target });
@@ -2600,6 +2556,7 @@ function WorkspaceScreenContent({
});
const closedKeys = new Set(tabsToClose.map((tab) => tab.key));
setHoveredTabKey((current) => (current && closedKeys.has(current) ? null : current));
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
},
[client, closeTab, closeWorkspaceTabWithCleanup, persistenceKey],
@@ -2870,6 +2827,7 @@ function WorkspaceScreenContent({
}) =>
buildWorkspacePaneContentModel({
tab: input.tab,
paneId: input.paneId,
normalizedServerId,
normalizedWorkspaceId,
onOpenTab: (target) => {
@@ -2894,19 +2852,26 @@ function WorkspaceScreenContent({
retargetWorkspaceTab(persistenceKey, input.tab.tabId, target);
},
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => {
handleOpenWorkspaceFileFromPane({
request,
paneId: input.paneId,
parentTabId: input.tab.tabId,
focusPaneBeforeOpen: input.focusPaneBeforeOpen,
});
if (input.focusPaneBeforeOpen && input.paneId && persistenceKey) {
focusWorkspacePane(persistenceKey, input.paneId);
}
if (request.disposition === "side") {
handleOpenFileFromChatInSidePane({
location: request.location,
sourcePaneId: input.paneId ?? undefined,
parentTabId: input.tab.tabId,
});
return;
}
handleOpenFileFromChat(request.location, { parentTabId: input.tab.tabId });
},
onOpenImportSheet: openImportSheet,
}),
[
handleCloseTabById,
handleOpenFileFromChat,
handleOpenFileFromChatInSidePane,
focusWorkspacePane,
handleOpenWorkspaceFileFromPane,
navigateToTabId,
normalizedServerId,
normalizedWorkspaceId,
@@ -3229,9 +3194,9 @@ function WorkspaceScreenContent({
`${WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX}:${normalizedServerId}:${normalizedWorkspaceId}`,
[normalizedServerId, normalizedWorkspaceId],
);
const desktopSplitContent = useMemo(() => {
const desktopContent = useMemo(() => {
if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) {
return null;
return content;
}
return (
<SplitContainer
@@ -3243,6 +3208,7 @@ function WorkspaceScreenContent({
isWorkspaceFocused={isRouteFocused}
uiTabs={uiTabs}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={navigateToTabId}
@@ -3269,6 +3235,7 @@ function WorkspaceScreenContent({
/>
);
}, [
content,
canRenderDesktopPaneSplits,
workspaceLayout,
persistenceKey,
@@ -3301,7 +3268,6 @@ function WorkspaceScreenContent({
handleReorderTabsInPane,
renderSplitPaneEmptyState,
]);
const desktopContent = desktopSplitContent ?? content;
const workspaceCenterColumn = (
<View style={styles.centerColumn}>
@@ -3378,6 +3344,7 @@ function WorkspaceScreenContent({
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={navigateToTabId}
onCloseTab={handleCloseTabById}
@@ -3403,9 +3370,12 @@ function WorkspaceScreenContent({
<View style={styles.centerContent}>
{isMobile ? (
<MobileExplorerOpenGestureSurface onOpenExplorer={openExplorerForWorkspace}>
{content}
</MobileExplorerOpenGestureSurface>
<GestureDetector
gesture={explorerOpenGesture}
touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}
>
<View style={styles.content}>{content}</View>
</GestureDetector>
) : (
<View style={styles.content}>{desktopContent}</View>
)}
@@ -3416,46 +3386,44 @@ function WorkspaceScreenContent({
return (
gatedWorkspaceScreen ?? (
<WorkspaceFocusProvider workspaceKey={persistenceKey}>
<RenderProfile id="WorkspaceScreenContent">
<View style={containerStyle}>
<WorkspaceDocumentTitleEffectSlot
tab={activeTabDescriptor}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isRouteFocused={isRouteFocused}
/>
<View style={styles.threePaneRow}>
<FloatingPanelPortalHostNameProvider hostName={workspaceFloatingPanelPortalHostName}>
{workspaceCenterColumn}
</FloatingPanelPortalHostNameProvider>
<View style={containerStyle}>
<WorkspaceDocumentTitleEffectSlot
tab={activeTabDescriptor}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isRouteFocused={isRouteFocused}
/>
<View style={styles.threePaneRow}>
<FloatingPanelPortalHostNameProvider hostName={workspaceFloatingPanelPortalHostName}>
{workspaceCenterColumn}
</FloatingPanelPortalHostNameProvider>
<FloatingPanelPortalHost name={workspaceFloatingPanelPortalHostName} />
<FloatingPanelPortalHost name={workspaceFloatingPanelPortalHostName} />
{showExplorerSidebar && workspaceDirectory ? (
<ExplorerSidebar
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
workspaceRoot={workspaceDirectory}
isGit={isGitCheckout}
onOpenFile={handleOpenFileFromExplorer}
/>
) : null}
</View>
<ImportSessionSheet
visible={isImportSheetVisible}
client={client}
serverId={normalizedServerId}
cwd={workspaceDirectory}
onClose={closeImportSheet}
onImportedAgent={handleImportedAgent}
/>
<WorkspaceTabRenameModal
renamingTab={renamingTab}
onSubmit={handleRenameModalSubmit}
onClose={handleRenameModalClose}
/>
{showExplorerSidebar && workspaceDirectory ? (
<ExplorerSidebar
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
workspaceRoot={workspaceDirectory}
isGit={isGitCheckout}
onOpenFile={handleOpenFileFromExplorer}
/>
) : null}
</View>
</RenderProfile>
<ImportSessionSheet
visible={isImportSheetVisible}
client={client}
serverId={normalizedServerId}
cwd={workspaceDirectory}
onClose={closeImportSheet}
onImportedAgent={handleImportedAgent}
/>
<WorkspaceTabRenameModal
renamingTab={renamingTab}
onSubmit={handleRenameModalSubmit}
onClose={handleRenameModalClose}
/>
</View>
</WorkspaceFocusProvider>
)
);
@@ -1,6 +1,5 @@
import invariant from "tiny-invariant";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { MIN_SPLIT_SIZE } from "@/stores/workspace-layout-constants";
import { defaultWorkspaceLayoutIds } from "@/stores/workspace-layout-ids";
import type { WorkspaceLayoutNodeIdPrefix } from "@/stores/workspace-layout-ids";
import {
@@ -209,6 +208,7 @@ export interface WorkspaceTabSnapshot {
}
const DEFAULT_PANE_ID = "main";
const MIN_SPLIT_SIZE = 0.1;
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {

Some files were not shown because too many files have changed in this diff Show More