Compare commits

..
309 changed files with 4898 additions and 13467 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 |
+2 -4
View File
@@ -2,8 +2,8 @@
Agent chat delivery has two paths:
1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates.
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas.
1. **Live stream** — `agent_stream` WebSocket messages for immediacy.
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness.
The invariant is:
@@ -24,8 +24,6 @@ Heartbeat is used for notification routing. It must not be used as a correctness
Large unbounded timeline responses can exceed relay frame limits, so catch-up uses bounded pages. Bounded does not mean partial.
Page limits are projected-item targets. A tool call lifecycle is one projected item even if it spans many source sequence numbers, and assistant/reasoning chunks are merged before counting. The response carries `seqStart`, `seqEnd`, `sourceSeqRanges`, and `collapsed` so clients can advance sequence cursors without rendering delta rows.
When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`.
The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward.
-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": [
@@ -6,7 +6,6 @@ import {
installAcpCatalogProvider,
openAddProviderModal,
openSettingsHost,
openSettingsHostSection,
} from "./helpers/settings";
const ACP_PROVIDER = {
@@ -19,8 +18,6 @@ test.describe("ACP provider catalog", () => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getServerId());
// Providers moved to their own host section; add-provider lives there now.
await openSettingsHostSection(page, getServerId(), "providers");
await openAddProviderModal(page);
await installAcpCatalogProvider(page, ACP_PROVIDER.name);
+1 -4
View File
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
import { expect, type Page } from "@playwright/test";
import { openSettings } from "./app";
import { getE2EDaemonPort } from "./daemon-port";
import { openSettingsHost, openSettingsHostSection } from "./settings";
import { openSettingsHost } from "./settings";
interface DaemonApiStatus {
version: string;
@@ -208,9 +208,6 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
await openSettings(page);
await openSettingsHost(page, serverId);
// The daemon-lifecycle card moved to the Daemon section in the flat-settings
// layout; navigate there before asserting it.
await openSettingsHostSection(page, serverId, "daemon");
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({
timeout: 15_000,
});
+2 -2
View File
@@ -320,12 +320,12 @@ async function fetchTimelineEpoch(handle: AgentHandle): Promise<string | undefin
const client = handle.client as SeedDaemonClient & {
fetchAgentTimeline: (
agentId: string,
options?: { direction?: "head" | "tail"; projection?: "projected"; limit?: number },
options?: { direction?: "head" | "tail"; projection?: "canonical"; limit?: number },
) => Promise<{ epoch?: string }>;
};
const timeline = await client.fetchAgentTimeline(handle.agentId, {
direction: "tail",
projection: "projected",
projection: "canonical",
limit: 0,
});
return timeline.epoch;
+22 -74
View File
@@ -1,11 +1,9 @@
import { expect, type Page } from "@playwright/test";
import { TEST_HOST_LABEL } from "./daemon-registry";
import { escapeRegex } from "./regex";
import { getServerId } from "./server-id";
const SECTION_LABELS = {
general: "General",
appearance: "Appearance",
shortcuts: "Shortcuts",
integrations: "Integrations",
permissions: "Permissions",
@@ -15,8 +13,6 @@ const SECTION_LABELS = {
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
type HostSection = "connections" | "orchestration" | "providers" | "daemon";
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
@@ -32,22 +28,8 @@ export async function openSettingsSection(page: Page, section: SettingsSection):
}
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
// Host sections are now flat top-level rows under the Host group. Navigate by
// clicking the Connections section row; the picker only matters when >1 host.
await page.getByTestId("settings-host-section-connections").click();
await expectHostSettingsUrl(page, serverId);
await expect(page.getByTestId("host-page-connections-card")).toBeVisible();
}
export async function openSettingsHostSection(
page: Page,
serverId: string,
section: HostSection,
): Promise<void> {
await page.getByTestId(`settings-host-section-${section}`).click();
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/${section}$`),
);
await page.getByTestId(`settings-host-entry-${serverId}`).click();
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
}
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
@@ -55,9 +37,6 @@ export async function expectSettingsHeader(page: Page, title: string): Promise<v
}
export async function openAddHostFlow(page: Page): Promise<void> {
// "Add host" is now an item inside the host picker (a Combobox); open the
// picker first, then pick it. The picker renders whenever a host exists.
await page.getByTestId("settings-host-picker").click();
await page.getByTestId("settings-add-host").click();
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
}
@@ -89,7 +68,7 @@ export async function expectCompactSettingsList(page: Page): Promise<void> {
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
await expect(page.getByTestId("host-page-connections-card")).toHaveCount(0);
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
}
export async function expectSettingsSidebarVisible(page: Page): Promise<void> {
@@ -126,7 +105,7 @@ export async function clickSettingsBackToWorkspace(page: Page): Promise<void> {
export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> {
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/connections$`),
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
);
}
@@ -181,11 +160,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> {
@@ -206,11 +181,7 @@ export async function expectHostLabelEditMode(page: Page, expectedLabel: string)
export async function expectHostConnectionsCard(page: Page, port: string): Promise<void> {
const card = page.getByTestId("host-page-connections-card");
await expect(card).toBeVisible();
// "Connections" appears three times on this page: the sidebar section row, the
// detail header title, and the SettingsSection heading above the card. Match
// the first to keep the heading assertion without tripping Playwright strict
// mode.
await expect(page.getByText("Connections", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Connections", { exact: true })).toBeVisible();
await expect(
card.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
).toBeVisible();
@@ -222,29 +193,14 @@ export async function expectHostInjectMcpCard(page: Page): Promise<void> {
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
}
export async function openHostSection(
page: Page,
serverId: string,
section: HostSection,
): Promise<void> {
await openSettingsHostSection(page, serverId, section);
}
export async function expectHostActionCards(page: Page, serverId: string): Promise<void> {
// Restart + remove cards live on the Daemon section; providers moved to its
// own Providers section (asserted via expectHostProvidersCard).
await openSettingsHostSection(page, serverId, "daemon");
export async function expectHostActionCards(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
}
export async function expectHostProvidersCard(page: Page, serverId: string): Promise<void> {
await openSettingsHostSection(page, serverId, "providers");
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
}
export async function serveJson(page: Page, url: string, body: unknown): Promise<void> {
await page.route(url, async (route) => {
await route.fulfill({
@@ -288,35 +244,27 @@ export async function expectHostNoLocalOnlyRows(page: Page): Promise<void> {
export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
// App group rows remain top-level.
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
// Host group rows are now flat top-level sections (no drill-in).
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-orchestration")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-providers")).toBeVisible();
await expect(sidebar.getByTestId("settings-host-section-daemon")).toBeVisible();
// The old per-host entry rows are replaced by the host picker.
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]')).toHaveCount(0);
}
export async function expectHostPageVisible(page: Page, _serverId: string): Promise<void> {
await expect(page.getByTestId("host-page-connections-card")).toBeVisible();
export async function expectHostPageVisible(page: Page, serverId: string): Promise<void> {
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
}
export async function expectLocalHostEntryFirst(page: Page, _serverId: string): Promise<void> {
export async function expectLocalHostEntryFirst(page: Page, serverId: string): Promise<void> {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible({ timeout: 15_000 });
// Single-host fixture: the picker is a non-interactive chip (no dropdown to
// open) that surfaces the local host by its label. The "Local" marker only
// appears on dropdown rows in the multi-host case, which this fixture does not
// exercise.
const picker = sidebar.getByTestId("settings-host-picker");
await expect(picker).toBeVisible();
await expect(picker.getByText(TEST_HOST_LABEL, { exact: true })).toBeVisible();
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]').first()).toHaveAttribute(
"data-testid",
`settings-host-entry-${serverId}`,
);
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
}
+12 -48
View File
@@ -6,14 +6,12 @@ import { getServerId } from "./helpers/server-id";
import {
expectSettingsHeader,
openSettingsHost,
openHostSection,
expectHostLabelDisplayed,
clickEditHostLabel,
expectHostLabelEditMode,
expectHostConnectionsCard,
expectHostInjectMcpCard,
expectHostActionCards,
expectHostProvidersCard,
expectHostNoLocalOnlyRows,
expectRetiredSidebarSectionsAbsent,
expectHostPageVisible,
@@ -21,7 +19,9 @@ import {
} from "./helpers/settings";
test.describe("Settings host page", () => {
test("connections section shows the seeded connection endpoint", async ({ page }) => {
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
page,
}) => {
const serverId = getServerId();
const port = getE2EDaemonPort();
@@ -29,44 +29,11 @@ test.describe("Settings host page", () => {
await openSettings(page);
await openSettingsHost(page, serverId);
await expectSettingsHeader(page, "Connections");
await expectHostConnectionsCard(page, port);
});
test("orchestration section shows the inject MCP toggle", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "orchestration");
await expectSettingsHeader(page, "Orchestration");
await expectHostInjectMcpCard(page);
});
test("providers section shows the providers card", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await expectHostProvidersCard(page, serverId);
await expectSettingsHeader(page, "Providers");
});
test("daemon section shows the host label and restart/remove action cards", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "daemon");
await expectSettingsHeader(page, "Daemon");
await expectSettingsHeader(page, TEST_HOST_LABEL);
await expectHostLabelDisplayed(page);
await expectHostActionCards(page, serverId);
await expectHostConnectionsCard(page, port);
await expectHostInjectMcpCard(page);
await expectHostActionCards(page);
});
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
@@ -75,14 +42,13 @@ test.describe("Settings host page", () => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "daemon");
await expectHostLabelDisplayed(page);
await clickEditHostLabel(page);
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
});
test("daemon section does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
page,
}) => {
const serverId = getServerId();
@@ -90,20 +56,19 @@ test.describe("Settings host page", () => {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "daemon");
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
await expectHostNoLocalOnlyRows(page);
});
test("settings sidebar exposes the flat App and Host section rows", async ({ page }) => {
test("settings sidebar does not expose retired top-level sections", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await expectRetiredSidebarSectionsAbsent(page);
});
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({
page,
}) => {
const serverId = getServerId();
@@ -112,10 +77,9 @@ test.describe("Settings host page", () => {
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
await expectHostPageVisible(page, serverId);
await expectSettingsHeader(page, "Connections");
await openHostSection(page, serverId, "daemon");
await expectSettingsHeader(page, TEST_HOST_LABEL);
await expectHostLabelDisplayed(page);
await expectHostActionCards(page, serverId);
await expectHostActionCards(page);
});
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
+2 -9
View File
@@ -25,7 +25,6 @@ import {
expectDiagnosticsContent,
expectAboutContent,
expectGeneralContent,
expectAppearanceContent,
} from "./helpers/settings";
test.describe("Settings sidebar navigation", () => {
@@ -44,13 +43,9 @@ 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 }) => {
test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => {
await gotoAppShell(page);
await verifyLegacyHostSettingsRedirect(page);
});
@@ -137,9 +132,7 @@ test.describe("Settings — compact master-detail", () => {
await expectSettingsBackButton(page);
});
test("tapping a host section row pushes /settings/hosts/[serverId]/connections", async ({
page,
}) => {
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
await gotoAppShell(page);
await openCompactSettings(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();
+93 -77
View File
@@ -41,7 +41,7 @@ import { PlanCard } from "@/components/plan-card";
import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared";
import type {
AgentCapabilityFlags,
AgentPlanAction,
AgentPermissionAction,
AgentPermissionResponse,
} from "@getpaseo/protocol/agent-types";
@@ -78,7 +78,6 @@ import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
import { recordRenderProfileReasons } from "@/utils/render-profiler";
function renderLiveAuxiliaryNode(input: {
pendingPermissions: ReactNode;
@@ -115,6 +114,86 @@ function renderPendingPermissionsNode(input: {
);
}
function PlanTimelineCard({
item,
agentId,
client,
}: {
item: Extract<StreamItem, { kind: "plan" }>;
agentId: string;
client: DaemonClient | null;
}) {
const [respondingActionId, setRespondingActionId] = useState<string | null>(null);
const respondToPlan = useMutation({
mutationFn: async (action: AgentPlanAction) => {
if (!client) {
throw new Error("No daemon connection");
}
setRespondingActionId(action.id);
const result = await client.respondToPlan(agentId, item.planId, { actionId: action.id });
if (!result.ok) {
throw new Error(result.error ?? "Failed to respond to plan");
}
},
onSettled: () => setRespondingActionId(null),
});
const actions = item.actions ?? [];
return (
<View>
<PlanCard title="Plan" text={item.text} testID="timeline-plan-card" />
{actions.length > 0 ? (
<View style={permissionStyles.optionsContainer}>
{actions.map((action) => (
<PlanActionButton
key={action.id}
action={action}
respondingActionId={respondingActionId}
isResponding={respondToPlan.isPending}
onRespond={respondToPlan.mutate}
/>
))}
</View>
) : null}
</View>
);
}
function PlanActionButton({
action,
respondingActionId,
isResponding,
onRespond,
}: {
action: AgentPlanAction;
respondingActionId: string | null;
isResponding: boolean;
onRespond: (action: AgentPlanAction) => void;
}) {
const Icon = action.variant === "danger" ? ThemedXIcon : ThemedCheckIcon;
const permissionAction = useMemo<AgentPermissionAction>(
() => ({
...action,
behavior: action.variant === "danger" ? "deny" : "allow",
}),
[action],
);
const handlePress = useCallback(() => onRespond(action), [action, onRespond]);
return (
<PermissionActionButton
action={permissionAction}
isRespondingAction={respondingActionId === action.id}
isResponding={isResponding}
isPrimary={action.variant === "primary"}
Icon={Icon}
testID={`plan-action-${action.id}`}
onPress={handlePress}
/>
);
}
function renderStreamItemWithTurnFooter(input: {
content: ReactNode;
layoutItem: StreamLayoutItem;
@@ -216,18 +295,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>(
@@ -563,6 +630,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
case "todo_list":
return <TodoListCard items={item.items} />;
case "plan":
return <PlanTimelineCard item={item} agentId={agentId} client={client} />;
case "compaction":
return (
<CompactionMarker
@@ -576,7 +646,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return null;
}
},
[renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem],
[
agentId,
client,
renderUserMessageItem,
renderAssistantMessageItem,
renderThoughtItem,
renderToolCallItem,
],
);
const bottomTurnFooterHost = streamLayout.auxiliaryTurnFooter;
@@ -759,68 +836,7 @@ 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";
interface ToolCallSlotProps extends Omit<
+1 -24
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} />
@@ -889,8 +867,7 @@ function RootStack() {
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="settings/hosts/[serverId]/index" />
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
<Stack.Screen name="settings/hosts/[serverId]" />
</Stack>
);
}
@@ -0,0 +1,16 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
export default function SettingsHostRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
const view = useMemo(() => ({ kind: "host" as const, serverId }), [serverId]);
return (
<HostRouteBootstrapBoundary>
<SettingsScreen view={view} />
</HostRouteBootstrapBoundary>
);
}
@@ -1,19 +0,0 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
import { type HostSectionSlug, isHostSectionSlug } from "@/utils/host-routes";
export default function SettingsHostSectionRoute() {
const params = useLocalSearchParams<{ serverId?: string; hostSection?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
const rawSection = typeof params.hostSection === "string" ? params.hostSection : "";
const section: HostSectionSlug = isHostSectionSlug(rawSection) ? rawSection : "connections";
const view = useMemo(() => ({ kind: "host" as const, serverId, section }), [serverId, section]);
return (
<HostRouteBootstrapBoundary>
<SettingsScreen view={view} />
</HostRouteBootstrapBoundary>
);
}
@@ -1,13 +0,0 @@
import { Redirect, useLocalSearchParams } from "expo-router";
import { buildSettingsHostSectionRoute, buildSettingsRoute } from "@/utils/host-routes";
export default function SettingsHostIndexRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
if (!serverId) {
return <Redirect href={buildSettingsRoute()} />;
}
return <Redirect href={buildSettingsHostSectionRoute(serverId, "connections")} />;
}
+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>
);
@@ -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>
);
}
@@ -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,
},
+4 -4
View File
@@ -11,6 +11,7 @@ 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";
@@ -18,7 +19,6 @@ import { highlightCode, 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";
@@ -172,14 +172,14 @@ 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,
@@ -296,7 +296,7 @@ function FilePreviewBody({
lineNumber: index + 1,
}));
const codeLines = (
<View dataSet={CODE_SURFACE_DATASET}>
<View>
{keyedLines.map(({ key, tokens, lineNumber }) => (
<CodeLine
key={key}
@@ -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 {
+17 -88
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";
@@ -1497,18 +1496,16 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
onLinkPress,
}: MemoizedMarkdownBlockProps) {
return (
<AppearanceStyleBoundary>
<ThemedMarkdown
uniProps={markdownStyleMapping}
rules={rules}
markdownit={parser}
onLinkPress={onLinkPress}
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
>
{text}
</ThemedMarkdown>
</AppearanceStyleBoundary>
<ThemedMarkdown
uniProps={markdownStyleMapping}
rules={rules}
markdownit={parser}
onLinkPress={onLinkPress}
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
>
{text}
</ThemedMarkdown>
);
});
@@ -1516,7 +1513,6 @@ interface MarkdownInheritedTextProps {
inheritedStyles: TextStyle;
textStyle: TextStyle;
style?: StyleProp<TextStyle>;
monoSurface?: boolean;
children: ReactNode;
}
@@ -1524,18 +1520,13 @@ function MarkdownInheritedText({
inheritedStyles,
textStyle,
style: overrideStyle,
monoSurface,
children,
}: MarkdownInheritedTextProps) {
const style = useMemo(
() => [inheritedStyles, textStyle, overrideStyle],
[inheritedStyles, textStyle, overrideStyle],
);
return (
<MarkdownTextSpan monoSurface={monoSurface} style={style}>
{children}
</MarkdownTextSpan>
);
return <MarkdownTextSpan style={style}>{children}</MarkdownTextSpan>;
}
interface MarkdownListItemContentProps {
@@ -1622,67 +1613,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 +1696,6 @@ export const AssistantMessage = memo(function AssistantMessage({
key={node.key}
inheritedStyles={inheritedStyles}
textStyle={styles.code_inline}
monoSurface
>
{content}
</MarkdownInheritedText>
@@ -1931,13 +1860,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 +1976,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 +2072,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 +2108,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],
@@ -47,7 +47,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
: undefined;
await input.client.fetchAgentTimeline(input.agentId, {
direction: "tail",
projection: "projected",
projection: "canonical",
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
});
}
@@ -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>
);
}
@@ -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]);
@@ -134,15 +134,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 +214,11 @@ export default function TerminalEmulator({
cursor: "#e6e6e6",
},
scrollbackLines,
fontFamily,
fontSize,
swipeGesturesEnabled = false,
onSwipeLeft,
onSwipeRight,
initialSnapshot = null,
onInput,
onFocus,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
@@ -240,12 +234,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);
@@ -494,8 +484,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 +523,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 +547,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 +742,8 @@ export default function TerminalEmulator({
}, []);
const handleRootPointerDown = useCallback(() => {
onFocus?.();
runtimeRef.current?.focus();
}, [onFocus]);
}, []);
const handleRootContextMenu = useCallback(
(event: ReactMouseEvent) => {
+52 -64
View File
@@ -173,10 +173,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);
@@ -218,7 +214,6 @@ export function TerminalPane({
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
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 });
useEffect(() => {
@@ -257,7 +252,7 @@ export function TerminalPane({
);
useEffect(() => {
if (isMobile || !isPaneFocused || !terminalId) {
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
lastAutoFocusKeyRef.current = null;
return;
}
@@ -268,36 +263,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 +308,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 +375,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 +605,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 }) => {
@@ -767,15 +758,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}>
+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",
},
@@ -34,7 +34,7 @@ afterEach(() => {
});
describe("ensureAgentIsInitialized", () => {
it("requests bounded projected catch-up after the current cursor when authoritative history is loaded", () => {
it("requests bounded canonical catch-up after the current cursor when authoritative history is loaded", () => {
const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never);
useSessionStore
@@ -56,12 +56,12 @@ describe("ensureAgentIsInitialized", () => {
direction: "after",
cursor: { epoch: "epoch-1", seq: 42 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
projection: "canonical",
});
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("after");
});
it("requests a bounded projected tail when no authoritative cursor is available", () => {
it("requests a bounded canonical tail when no authoritative cursor is available", () => {
const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never);
@@ -75,7 +75,7 @@ describe("ensureAgentIsInitialized", () => {
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
projection: "canonical",
});
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
});
@@ -107,7 +107,7 @@ describe("ensureAgentIsInitialized", () => {
});
describe("refreshAgent", () => {
it("fetches a bounded projected tail after refreshing the agent", async () => {
it("fetches a bounded canonical tail after refreshing the agent", async () => {
const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never);
@@ -121,7 +121,7 @@ describe("refreshAgent", () => {
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
projection: "canonical",
});
});
});
@@ -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) => {
@@ -147,7 +147,7 @@ describe("loadOlderAgentHistory", () => {
direction: "before",
cursor: { epoch: "epoch-1", seq: 10 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
projection: "canonical",
},
},
]);
@@ -10,7 +10,7 @@ export interface LoadOlderAgentHistoryClient {
direction: "before";
cursor: { epoch: string; seq: number };
limit: number;
projection: "projected";
projection: "canonical";
},
) => Promise<unknown>;
}
@@ -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;
+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>
);
+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,
@@ -17,7 +17,7 @@ import {
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
import { buildHostAgentDetailRoute, buildSettingsHostSectionRoute } from "@/utils/host-routes";
import { buildHostAgentDetailRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { ImportSessionSheet } from "@/components/import-session-sheet";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useOpenProject } from "@/hooks/use-open-project";
@@ -62,7 +62,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) {
);
const handleOpenProviders = useCallback(() => {
router.push(buildSettingsHostSectionRoute(serverId, "providers"));
router.push(buildSettingsHostRoute(serverId));
}, [router, serverId]);
return (
+213 -388
View File
@@ -1,13 +1,5 @@
import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import type { ComponentType, ReactElement, ReactNode } from "react";
import { Fragment, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import type { ComponentType, ReactNode } from "react";
import {
Alert,
Pressable,
@@ -24,14 +16,12 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Buffer } from "buffer";
import {
ArrowLeft,
ChevronDown,
Sun,
Moon,
Monitor,
ChevronDown,
Settings,
Palette,
Server,
Network,
Workflow,
Boxes,
Keyboard,
Stethoscope,
Info,
@@ -45,7 +35,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 +44,7 @@ import {
type ServiceUrlBehavior,
type Settings as EffectiveSettings,
} from "@/hooks/use-settings";
import { THEME_SWATCHES } from "@/styles/theme";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
@@ -79,9 +69,9 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { DesktopPermissionsSection } from "@/desktop/components/desktop-permissions-section";
import { IntegrationsSection } from "@/desktop/components/integrations-section";
import { isElectronRuntime } from "@/desktop/host";
@@ -91,12 +81,7 @@ import { resolveAppVersion } from "@/utils/app-version";
import { settingsStyles } from "@/styles/settings";
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
import {
HostConnectionsPage,
HostDaemonPage,
HostOrchestrationPage,
HostProvidersPage,
} from "@/screens/settings/host-page";
import { HostPage, HostRenameButton } from "@/screens/settings/host-page";
import ProjectsScreen from "@/screens/projects-screen";
import ProjectSettingsScreen from "@/screens/project-settings-screen";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -105,9 +90,8 @@ import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
import {
buildHostOpenProjectRoute,
buildProjectsSettingsRoute,
buildSettingsHostSectionRoute,
buildSettingsHostRoute,
buildSettingsSectionRoute,
type HostSectionSlug,
type SettingsSectionSlug,
} from "@/utils/host-routes";
import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-store";
@@ -119,7 +103,7 @@ import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-st
export type SettingsView =
| { kind: "root" }
| { kind: "section"; section: SettingsSectionSlug }
| { kind: "host"; serverId: string; section: HostSectionSlug }
| { kind: "host"; serverId: string }
| { kind: "projects" }
| { kind: "project"; projectKey: string };
@@ -132,7 +116,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 },
@@ -140,22 +123,45 @@ const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
{ id: "about", label: "About", icon: Info },
];
interface HostSectionItem {
id: HostSectionSlug;
label: string;
icon: ComponentType<{ size: number; color: string }>;
// ---------------------------------------------------------------------------
// 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} />;
}
}
const HOST_SECTION_ITEMS: HostSectionItem[] = [
{ id: "connections", label: "Connections", icon: Network },
{ id: "orchestration", label: "Orchestration", icon: Workflow },
{ id: "providers", label: "Providers", icon: Boxes },
{ id: "daemon", label: "Daemon", icon: Server },
];
// ---------------------------------------------------------------------------
// Trigger + sidebar style helpers
// ---------------------------------------------------------------------------
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 +179,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 +216,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 +275,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 +312,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}>
@@ -626,26 +709,6 @@ function useAnyOnlineHostServerId(serverIds: string[]): string | null {
);
}
/**
* Local daemon first, then remaining hosts in their existing order. Lets the
* picker and the active-host resolver agree on a stable "first" host.
*/
function useSortedHosts(hosts: HostProfile[], localServerId: string | null): HostProfile[] {
return useMemo(() => {
if (!localServerId) {
return hosts;
}
const localIndex = hosts.findIndex((host) => host.serverId === localServerId);
if (localIndex <= 0) {
return hosts;
}
const next = hosts.slice();
const [local] = next.splice(localIndex, 1);
next.unshift(local);
return next;
}, [hosts, localServerId]);
}
interface SidebarSectionButtonProps {
itemId: SettingsSectionSlug;
label: string;
@@ -688,49 +751,6 @@ function SidebarSectionButton({
);
}
interface SidebarHostSectionButtonProps {
itemId: HostSectionSlug;
label: string;
icon: ComponentType<{ size: number; color: string }>;
isSelected: boolean;
onSelect: (section: HostSectionSlug) => void;
}
function SidebarHostSectionButton({
itemId,
label,
icon: IconComponent,
isSelected,
onSelect,
}: SidebarHostSectionButtonProps) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => {
onSelect(itemId);
}, [onSelect, itemId]);
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
const labelStyle = useMemo(
() => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }],
[isSelected, theme.colors.foreground],
);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={handlePress}
testID={`settings-host-section-${itemId}`}
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
>
<IconComponent
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={labelStyle} numberOfLines={1}>
{label}
</Text>
</Pressable>
);
}
interface SidebarProjectsButtonProps {
isSelected: boolean;
onSelect: () => void;
@@ -762,213 +782,83 @@ function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonPr
);
}
// Sentinel option id for the "Add host" row appended to the picker list.
const ADD_HOST_OPTION_ID = "__add_host__";
interface HostPickerOptionProps {
interface SidebarHostItemProps {
serverId: string;
label: string;
isSelected: boolean;
isLocal: boolean;
selected: boolean;
active: boolean;
onPress: () => void;
onSelect: (serverId: string) => void;
}
function HostPickerOption({
serverId,
label,
isLocal,
selected,
active,
onPress,
}: HostPickerOptionProps) {
function SidebarHostItem({ serverId, label, isSelected, isLocal, onSelect }: SidebarHostItemProps) {
const { theme } = useUnistyles();
const leadingSlot = useMemo(
() => <Server size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[theme.iconSize.sm, theme.colors.foregroundMuted],
const handlePress = useCallback(() => {
onSelect(serverId);
}, [onSelect, serverId]);
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]);
const labelStyle = useMemo(
() => [sidebarStyles.label, isSelected && { color: theme.colors.foreground }],
[isSelected, theme.colors.foreground],
);
// The local host carries a "Local" marker; the active host is conveyed by the
// row's selected check, so both can coexist on one row.
const trailingSlot = useMemo(
() =>
isLocal ? (
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={handlePress}
testID={`settings-host-entry-${serverId}`}
style={isSelected ? selectedSidebarItemStyle : sidebarItemStyle}
>
<Server
size={theme.iconSize.md}
color={isSelected ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text style={labelStyle} numberOfLines={1}>
{label}
</Text>
{isLocal ? (
<Text style={sidebarStyles.localMarker} testID="settings-host-local-marker">
Local
</Text>
) : undefined,
[isLocal],
);
return (
<ComboboxItem
label={label}
leadingSlot={leadingSlot}
trailingSlot={trailingSlot}
selected={selected}
active={active}
onPress={onPress}
testID={`settings-host-picker-item-${serverId}`}
/>
);
}
function AddHostOption({ active, onPress }: { active: boolean; onPress: () => void }) {
const { theme } = useUnistyles();
const leadingSlot = useMemo(
() => <Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[theme.iconSize.sm, theme.colors.foregroundMuted],
);
return (
<ComboboxItem
label="Add host"
leadingSlot={leadingSlot}
active={active}
onPress={onPress}
testID="settings-add-host"
/>
);
}
interface HostPickerProps {
activeServerId: string | null;
sortedHosts: HostProfile[];
localServerId: string | null;
onSelectHost: (serverId: string) => void;
onAddHost: () => void;
}
/**
* Scopes the four host sections to a host. Reuses the canonical sidebar host
* switcher pattern (left-sidebar.tsx): a quiet row-styled trigger opening a
* <Combobox>. The local host is listed first and tagged "Local"; an "Add host"
* row is always reachable from the list — even with a single host.
*/
function HostPicker({
activeServerId,
sortedHosts,
localServerId,
onSelectHost,
onAddHost,
}: HostPickerProps) {
const { theme } = useUnistyles();
const [isOpen, setIsOpen] = useState(false);
const triggerRef = useRef<View | null>(null);
const activeHost =
sortedHosts.find((host) => host.serverId === activeServerId) ?? sortedHosts[0] ?? null;
const options = useMemo<ComboboxOption[]>(() => {
const hostOptions = sortedHosts.map((host) => ({ id: host.serverId, label: host.label }));
return [...hostOptions, { id: ADD_HOST_OPTION_ID, label: "Add host" }];
}, [sortedHosts]);
const handleSelect = useCallback(
(id: string) => {
if (id === ADD_HOST_OPTION_ID) {
onAddHost();
return;
}
onSelectHost(id);
},
[onAddHost, onSelectHost],
);
const renderOption = useCallback(
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}): ReactElement => {
if (option.id === ADD_HOST_OPTION_ID) {
return <AddHostOption active={active} onPress={onPress} />;
}
return (
<HostPickerOption
serverId={option.id}
label={option.label}
isLocal={localServerId !== null && option.id === localServerId}
selected={selected}
active={active}
onPress={onPress}
/>
);
},
[localServerId],
);
const handleOpen = useCallback(() => setIsOpen(true), []);
const triggerStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
sidebarStyles.pickerTrigger,
hovered && sidebarStyles.pickerTriggerHovered,
],
[],
);
return (
<>
<Pressable
ref={triggerRef}
style={triggerStyle}
onPress={handleOpen}
accessibilityRole="button"
accessibilityLabel="Switch host"
testID="settings-host-picker"
>
<Monitor size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={sidebarStyles.pickerTriggerLabel} numberOfLines={1}>
{activeHost?.label ?? "Host"}
</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<Combobox
options={options}
value={activeServerId ?? ""}
onSelect={handleSelect}
renderOption={renderOption}
searchable={false}
title="Switch host"
desktopMinWidth={240}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={triggerRef}
/>
</>
) : null}
</Pressable>
);
}
interface SettingsSidebarProps {
view: SettingsView;
onSelectSection: (section: SettingsSectionSlug) => void;
onSelectHostSection: (section: HostSectionSlug) => void;
onSelectHost: (serverId: string) => void;
onSelectProjects: () => void;
onAddHost: () => void;
onBackToWorkspace: () => void;
activeHostServerId: string | null;
layout: "desktop" | "mobile";
}
function SettingsSidebar({
view,
onSelectSection,
onSelectHostSection,
onSelectHost,
onSelectProjects,
onAddHost,
onBackToWorkspace,
activeHostServerId,
layout,
}: SettingsSidebarProps) {
const { theme } = useUnistyles();
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const sortedHosts = useSortedHosts(hosts, localServerId);
const hasHosts = sortedHosts.length > 0;
const sortedHosts = useMemo(() => {
if (!localServerId) {
return hosts;
}
const localIndex = hosts.findIndex((host) => host.serverId === localServerId);
if (localIndex <= 0) {
return hosts;
}
const next = hosts.slice();
const [local] = next.splice(localIndex, 1);
next.unshift(local);
return next;
}, [hosts, localServerId]);
const isDesktopApp = isElectronRuntime();
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
const insets = useSafeAreaInsets();
@@ -982,7 +872,7 @@ function SettingsSidebar({
[insets.top, isDesktop],
);
const selectedSectionId = view.kind === "section" ? view.section : null;
const selectedHostSection = view.kind === "host" ? view.section : null;
const selectedServerId = view.kind === "host" ? view.serverId : null;
const isProjectsSelected = view.kind === "projects" || view.kind === "project";
const paddingTopStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
@@ -1003,7 +893,6 @@ function SettingsSidebar({
/>
) : null}
<View style={sidebarStyles.list}>
<Text style={sidebarStyles.groupLabel}>App</Text>
{items.map((item) => (
<Fragment key={item.id}>
<SidebarSectionButton
@@ -1020,43 +909,30 @@ function SettingsSidebar({
))}
</View>
<SidebarSeparator />
{hasHosts ? (
<View style={sidebarStyles.list}>
<Text style={sidebarStyles.groupLabel}>Host</Text>
<HostPicker
activeServerId={activeHostServerId}
sortedHosts={sortedHosts}
localServerId={localServerId}
onSelectHost={onSelectHost}
onAddHost={onAddHost}
<View style={sidebarStyles.list}>
{sortedHosts.map((host) => (
<SidebarHostItem
key={host.serverId}
serverId={host.serverId}
label={host.label}
isSelected={selectedServerId === host.serverId}
isLocal={localServerId !== null && host.serverId === localServerId}
onSelect={onSelectHost}
/>
{HOST_SECTION_ITEMS.map((item) => (
<SidebarHostSectionButton
key={item.id}
itemId={item.id}
label={item.label}
icon={item.icon}
isSelected={selectedHostSection === item.id}
onSelect={onSelectHostSection}
/>
))}
</View>
) : (
<View style={sidebarStyles.list}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Add host"
onPress={onAddHost}
testID="settings-add-host"
style={sidebarItemStyle}
>
<Plus size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sidebarStyles.label} numberOfLines={1}>
Add host
</Text>
</Pressable>
</View>
)}
))}
<Pressable
accessibilityRole="button"
accessibilityLabel="Add host"
onPress={onAddHost}
testID="settings-add-host"
style={sidebarItemStyle}
>
<Plus size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={sidebarStyles.label} numberOfLines={1}>
Add host
</Text>
</Pressable>
</View>
</View>
);
}
@@ -1091,17 +967,15 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
[webScrollbarStyle],
);
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const sortedHosts = useSortedHosts(hosts, localServerId);
const hostServerIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const anyOnlineServerId = useAnyOnlineHostServerId(hostServerIds);
// The host the four sections scope to: the host on the active view, otherwise
// the local daemon, otherwise the first available host.
const activeHostServerId = useMemo(() => {
if (view.kind === "host") return view.serverId;
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) => {
@@ -1181,7 +1055,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
const handleHostAdded = useCallback(
({ serverId }: { serverId: string }) => {
const target = buildSettingsHostSectionRoute(serverId, "connections");
const target = buildSettingsHostRoute(serverId);
if (isCompactLayout) {
router.push(target);
} else {
@@ -1203,34 +1077,16 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
[isCompactLayout, router],
);
// Picker: swap the host but keep the section the user is already looking at.
const handleSelectHost = useCallback(
(serverId: string) => {
const section: HostSectionSlug = view.kind === "host" ? view.section : "connections";
const target = buildSettingsHostSectionRoute(serverId, section);
const target = buildSettingsHostRoute(serverId);
if (isCompactLayout) {
router.push(target);
} else {
router.replace(target);
}
},
[isCompactLayout, router, view],
);
const handleSelectHostSection = useCallback(
(section: HostSectionSlug) => {
if (!activeHostServerId) {
handleAddHost();
return;
}
const target = buildSettingsHostSectionRoute(activeHostServerId, section);
if (isCompactLayout) {
router.push(target);
} else {
router.replace(target);
}
},
[activeHostServerId, handleAddHost, isCompactLayout, router],
[isCompactLayout, router],
);
const handleSelectProjects = useCallback(() => {
@@ -1284,9 +1140,13 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
titleAccessory?: ReactNode;
} | null => {
if (view.kind === "host") {
const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section);
if (!item) return null;
return { title: item.label, Icon: item.icon };
const host = hosts.find((h) => h.serverId === view.serverId);
if (!host) return null;
return {
title: host.label,
Icon: Server,
titleAccessory: <HostRenameButton host={host} />,
};
}
if (view.kind === "section") {
const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section);
@@ -1301,16 +1161,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
const content = (() => {
if (view.kind === "host") {
switch (view.section) {
case "connections":
return <HostConnectionsPage serverId={view.serverId} />;
case "orchestration":
return <HostOrchestrationPage serverId={view.serverId} />;
case "providers":
return <HostProvidersPage serverId={view.serverId} />;
case "daemon":
return <HostDaemonPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
}
return <HostPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
}
if (view.kind === "projects") {
return <ProjectsScreen view={view} />;
@@ -1325,13 +1176,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":
@@ -1401,12 +1251,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
<SettingsSidebar
view={view}
onSelectSection={handleSelectSection}
onSelectHostSection={handleSelectHostSection}
onSelectHost={handleSelectHost}
onSelectProjects={handleSelectProjects}
onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace}
activeHostServerId={activeHostServerId}
layout="mobile"
/>
</ScrollView>
@@ -1445,12 +1293,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
<SettingsSidebar
view={view}
onSelectSection={handleSelectSection}
onSelectHostSection={handleSelectHostSection}
onSelectHost={handleSelectHost}
onSelectProjects={handleSelectProjects}
onAddHost={handleAddHost}
onBackToWorkspace={handleBackToWorkspace}
activeHostServerId={activeHostServerId}
layout="desktop"
/>
<View style={desktopStyles.contentPane}>
@@ -1602,13 +1448,6 @@ const sidebarStyles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2],
gap: theme.spacing[1],
},
groupLabel: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
},
item: {
flexDirection: "row",
alignItems: "center",
@@ -1633,25 +1472,11 @@ const sidebarStyles = StyleSheet.create((theme) => ({
localMarker: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
marginLeft: theme.spacing[1],
},
pickerTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
pickerTriggerHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
pickerTriggerLabel: {
flex: 1,
minWidth: 0,
fontSize: theme.fontSize.base,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.normal,
paddingVertical: 2,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
},
}));
@@ -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]);
+65 -142
View File
@@ -74,30 +74,25 @@ function formatDaemonVersionBadge(version: string | null): string | null {
const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" };
const REMOVE_HOST_HEADER: SheetHeader = { title: "Remove host" };
function useHostProfile(serverId: string): HostProfile | null {
export interface HostPageProps {
serverId: string;
onHostRemoved?: () => void;
}
export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
const daemons = useHosts();
return daemons.find((entry) => entry.serverId === serverId) ?? null;
}
function HostNotFound() {
return (
<View>
<View style={EMPTY_CARD_STYLE}>
<Text style={styles.emptyText}>Host not found</Text>
</View>
</View>
);
}
function HostStatusBadges({ serverId }: { serverId: string }) {
const host = daemons.find((entry) => entry.serverId === serverId) ?? null;
const { theme } = useUnistyles();
const snapshot = useHostRuntimeSnapshot(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
const daemonVersion = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.version ?? null,
);
const connectionStatus = snapshot?.connectionStatus ?? "connecting";
const activeConnection = snapshot?.activeConnection ?? null;
const lastError = snapshot?.lastError ?? null;
const statusLabel = formatConnectionStatus(connectionStatus);
const statusTone = getConnectionStatusTone(connectionStatus);
let statusColor: string;
@@ -122,6 +117,8 @@ function HostStatusBadges({ serverId }: { serverId: string }) {
}
const connectionBadge = formatActiveConnectionBadge(activeConnection, theme);
const versionBadgeText = formatDaemonVersionBadge(daemonVersion);
const connectionError =
typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
const statusPillStyle = useMemo(
() => [styles.statusPill, { backgroundColor: statusPillBg }],
@@ -133,125 +130,46 @@ function HostStatusBadges({ serverId }: { serverId: string }) {
);
const statusTextStyle = useMemo(() => [styles.statusText, { color: statusColor }], [statusColor]);
return (
<View style={styles.identityBadges} testID="host-page-identity">
<View style={statusPillStyle}>
<View style={statusDotStyle} />
<Text style={statusTextStyle}>{statusLabel}</Text>
</View>
{connectionBadge ? (
<View style={styles.badgePill}>
{connectionBadge.icon}
<Text style={styles.badgeText} numberOfLines={1}>
{connectionBadge.text}
</Text>
</View>
) : null}
{versionBadgeText ? (
<View style={styles.badgePill}>
<Text style={styles.badgeText} numberOfLines={1}>
{versionBadgeText}
</Text>
</View>
) : null}
</View>
);
}
function HostConnectionError({ serverId }: { serverId: string }) {
const snapshot = useHostRuntimeSnapshot(serverId);
const lastError = snapshot?.lastError ?? null;
const connectionError =
typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
if (!connectionError) return null;
return <Text style={styles.errorText}>{connectionError}</Text>;
}
export function HostConnectionsPage({ serverId }: { serverId: string }) {
const host = useHostProfile(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
if (!host) {
return <HostNotFound />;
}
return (
<View>
<HostConnectionError serverId={serverId} />
<ConnectionsSection host={host} />
{isLocalDaemon ? (
<SettingsSection title="Pair devices">
<PairDeviceRow />
</SettingsSection>
) : null}
</View>
);
}
export function HostOrchestrationPage({ serverId }: { serverId: string }) {
const host = useHostProfile(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
if (!host) {
return <HostNotFound />;
}
return (
<View>
{isConnected ? (
<SettingsSection title="Orchestration">
<InjectPaseoToolsCard serverId={serverId} />
<AppendSystemPromptCard serverId={serverId} />
</SettingsSection>
) : (
return (
<View testID={`settings-host-page-${serverId}`}>
<View style={EMPTY_CARD_STYLE}>
<Text style={styles.emptyText}>Connect to this host to manage orchestration</Text>
<Text style={styles.emptyText}>Host not found</Text>
</View>
)}
</View>
);
}
export function HostProvidersPage({ serverId }: { serverId: string }) {
const host = useHostProfile(serverId);
if (!host) {
return <HostNotFound />;
}
return (
<View>
<ProvidersSection serverId={serverId} />
</View>
);
}
export function HostDaemonPage({
serverId,
onHostRemoved,
}: {
serverId: string;
onHostRemoved?: () => void;
}) {
const host = useHostProfile(serverId);
const isLocalDaemon = useIsLocalDaemon(serverId);
if (!host) {
return <HostNotFound />;
}
return (
<View>
<View style={styles.daemonHeader}>
<Text style={styles.daemonHeaderLabel} numberOfLines={1}>
{host.label}
</Text>
<HostRenameButton host={host} />
</View>
);
}
<HostStatusBadges serverId={serverId} />
return (
<View testID={`settings-host-page-${serverId}`}>
<View style={styles.identityBadges} testID="host-page-identity">
<View style={statusPillStyle}>
<View style={statusDotStyle} />
<Text style={statusTextStyle}>{statusLabel}</Text>
</View>
{connectionBadge ? (
<View style={styles.badgePill}>
{connectionBadge.icon}
<Text style={styles.badgeText} numberOfLines={1}>
{connectionBadge.text}
</Text>
</View>
) : null}
{versionBadgeText ? (
<View style={styles.badgePill}>
<Text style={styles.badgeText} numberOfLines={1}>
{versionBadgeText}
</Text>
</View>
) : null}
</View>
{connectionError ? <Text style={styles.errorText}>{connectionError}</Text> : null}
{isLocalDaemon ? <LocalDaemonSection /> : null}
<ConnectionsSection host={host} />
<DaemonSection host={host} isLocalDaemon={isLocalDaemon} />
<ProvidersSection serverId={serverId} />
<RemoveHostSection host={host} onRemoved={onHostRemoved} />
</View>
@@ -419,7 +337,7 @@ function ConnectionRow({
if (latencyLoading) return "...";
if (latencyError) return "Timeout";
if (latencyMs != null) return formatLatency(latencyMs);
return "—";
return "\u2014";
})();
const latencyColor = latencyError ? theme.colors.palette.red[300] : theme.colors.foregroundMuted;
@@ -460,6 +378,23 @@ function ConnectionRow({
);
}
function DaemonSection({ host, isLocalDaemon }: { host: HostProfile; isLocalDaemon: boolean }) {
return (
<>
<SettingsSection title="Daemon settings">
<InjectPaseoToolsCard serverId={host.serverId} />
<AppendSystemPromptCard serverId={host.serverId} />
</SettingsSection>
{isLocalDaemon ? (
<SettingsSection title="Pair devices">
<PairDeviceRow />
</SettingsSection>
) : null}
{isLocalDaemon ? <LocalDaemonSection /> : null}
</>
);
}
const delay = (ms: number) =>
new Promise<void>((resolve) => {
setTimeout(resolve, ms);
@@ -677,7 +612,7 @@ function AppendSystemPromptCard({ serverId }: { serverId: string }) {
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>System prompt</Text>
<Text style={settingsStyles.rowHint}>Adds a system prompt to all agents</Text>
<Text style={settingsStyles.rowHint}>Added a system prompt to all agents</Text>
</View>
<Button
variant="outline"
@@ -868,18 +803,6 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[1],
borderRadius: theme.borderRadius.md,
},
daemonHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
marginBottom: theme.spacing[4],
},
daemonHeaderLabel: {
flexShrink: 1,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
identityBadges: {
flexDirection: "row",
alignItems: "center",
@@ -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}
@@ -6,11 +6,9 @@ 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";
export interface WorkspacePaneContentModel {
@@ -74,35 +72,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({
@@ -114,14 +83,10 @@ export function WorkspacePaneContent({
);
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 });
@@ -2509,7 +2465,7 @@ function WorkspaceScreenContent({
const currentCursor = sessionState?.agentTimelineCursor.get(agentId);
await client.fetchAgentTimeline(agentId, {
direction: "tail",
projection: "projected",
projection: "canonical",
...(currentCursor
? { cursor: { epoch: currentCursor.epoch, seq: currentCursor.endSeq } }
: {}),
@@ -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],
@@ -2894,19 +2851,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 +3193,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 +3207,7 @@ function WorkspaceScreenContent({
isWorkspaceFocused={isRouteFocused}
uiTabs={uiTabs}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={navigateToTabId}
@@ -3269,6 +3234,7 @@ function WorkspaceScreenContent({
/>
);
}, [
content,
canRenderDesktopPaneSplits,
workspaceLayout,
persistenceKey,
@@ -3301,7 +3267,6 @@ function WorkspaceScreenContent({
handleReorderTabsInPane,
renderSplitPaneEmptyState,
]);
const desktopContent = desktopSplitContent ?? content;
const workspaceCenterColumn = (
<View style={styles.centerColumn}>
@@ -3378,6 +3343,7 @@ function WorkspaceScreenContent({
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={navigateToTabId}
onCloseTab={handleCloseTabById}
@@ -3403,9 +3369,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 +3385,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") {
@@ -1 +0,0 @@
export const MIN_SPLIT_SIZE = 0.1;
-7
View File
@@ -1,7 +0,0 @@
// Marker for code / diff / monospace surfaces. On web, the app-wide interface-font
// rule (see screens/settings/appearance/apply-root-font.web.ts) targets
// `#root *:not([data-pmono]):not([data-pmono] *)`, so tagging a code container with
// this dataSet excludes it and its subtree — they keep their monospace font. On
// native it renders nothing and is harmless. Use a shared stable reference so it
// doesn't trip the react-perf "new object as prop" rule.
export const CODE_SURFACE_DATASET = { pmono: "" } as const;
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { createCompactMarkdownStyles, createMarkdownStyles } from "./markdown-styles";
import { createMarkdownStyles } from "./markdown-styles";
import { darkTheme } from "./theme";
describe("createMarkdownStyles", () => {
@@ -69,28 +69,4 @@ describe("createMarkdownStyles", () => {
userSelect: "text",
});
});
it("uses the mono font-size token directly for inline and block code", () => {
const styles = createMarkdownStyles(darkTheme);
const compactStyles = createCompactMarkdownStyles(darkTheme);
expect(styles.code_inline).toMatchObject({
fontFamily: darkTheme.fontFamily.mono,
fontSize: darkTheme.fontSize.code,
lineHeight: Math.round(darkTheme.fontSize.code * 1.45),
});
expect(styles.code_block).toMatchObject({
fontFamily: darkTheme.fontFamily.mono,
fontSize: darkTheme.fontSize.code,
});
expect(styles.fence).toMatchObject({
fontFamily: darkTheme.fontFamily.mono,
fontSize: darkTheme.fontSize.code,
});
expect(compactStyles.code_inline).toMatchObject({
fontFamily: darkTheme.fontFamily.mono,
fontSize: darkTheme.fontSize.code,
lineHeight: Math.round(darkTheme.fontSize.code * 1.45),
});
});
});
+7 -9
View File
@@ -1,4 +1,5 @@
import type { Theme } from "./theme";
import { Fonts } from "@/constants/theme";
import { isWeb } from "@/constants/platform";
const webSelectableTextStyle = isWeb ? { userSelect: "text" as const } : {};
@@ -20,9 +21,7 @@ export function createMarkdownStyles(theme: Theme) {
...webSelectableTextStyle,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
// Prose line-height scales with the UI ramp (≈22 at base 16), NOT the
// code-size-coupled lineHeight.diff token used by code/diff surfaces.
lineHeight: Math.round(theme.fontSize.base * 1.4),
lineHeight: 22,
flexShrink: 1,
minWidth: 0,
width: "100%" as const,
@@ -170,9 +169,8 @@ export function createMarkdownStyles(theme: Theme) {
paddingVertical: 2,
borderRadius: theme.borderRadius.md,
borderWidth: 0,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.code,
lineHeight: Math.round(theme.fontSize.code * 1.45),
fontFamily: Fonts.mono,
fontSize: theme.fontSize.base - 3,
},
code_block: {
@@ -181,7 +179,7 @@ export function createMarkdownStyles(theme: Theme) {
color: theme.colors.foreground,
padding: theme.spacing[3],
borderRadius: theme.borderRadius.md,
fontFamily: theme.fontFamily.mono,
fontFamily: Fonts.mono,
fontSize: theme.fontSize.code,
marginVertical: theme.spacing[2],
},
@@ -194,7 +192,7 @@ export function createMarkdownStyles(theme: Theme) {
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
fontFamily: theme.fontFamily.mono,
fontFamily: Fonts.mono,
fontSize: theme.fontSize.code,
marginVertical: theme.spacing[3],
},
@@ -390,7 +388,7 @@ export function createCompactMarkdownStyles(theme: Theme) {
code_inline: {
...baseStyles.code_inline,
fontSize: theme.fontSize.code,
fontSize: theme.fontSize.sm - 3,
},
code_block: {
+2 -35
View File
@@ -1,4 +1,3 @@
import { Platform } from "react-native";
import { darkHighlightColors, lightHighlightColors } from "@getpaseo/highlight";
export const baseColors = {
@@ -486,48 +485,16 @@ export const OPACITY = {
100: 1,
} as const;
// Platform default font stacks — copied verbatim from constants/theme.ts `Fonts`
// (sans -> ui, mono -> mono). These seed the dynamic `fontFamily` theme token and
// are the fallback an empty user-supplied family resolves to at apply time.
export const DEFAULT_UI_FONT_STACK: string = Platform.select({
ios: "system-ui",
default: "normal",
web: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
});
export const DEFAULT_MONO_FONT_STACK: string = Platform.select({
ios: "ui-monospace",
default: "monospace",
web: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
});
// `fontSize`, `fontFamily`, and `lineHeight` are deliberately widened to plain
// `number`/`string` (not narrowed by `as const`) so the appearance updater can patch
// them at runtime via `UnistylesRuntime.updateTheme`. The remaining tokens keep their
// literal types.
interface CommonTheme {
spacing: typeof SPACING;
fontSize: Record<keyof typeof FONT_SIZE, number>;
fontFamily: { ui: string; mono: string };
lineHeight: Record<keyof typeof LINE_HEIGHT, number>;
iconSize: typeof ICON_SIZE;
fontWeight: typeof FONT_WEIGHT;
borderRadius: typeof BORDER_RADIUS;
borderWidth: typeof BORDER_WIDTH;
opacity: typeof OPACITY;
}
const commonTheme: CommonTheme = {
const commonTheme = {
spacing: SPACING,
fontSize: FONT_SIZE,
fontFamily: { ui: DEFAULT_UI_FONT_STACK, mono: DEFAULT_MONO_FONT_STACK },
lineHeight: LINE_HEIGHT,
iconSize: ICON_SIZE,
fontWeight: FONT_WEIGHT,
borderRadius: BORDER_RADIUS,
borderWidth: BORDER_WIDTH,
opacity: OPACITY,
};
} as const;
const darkShadow = {
sm: {
@@ -14,7 +14,6 @@ vi.mock("@xterm/addon-webgl", () => ({
interface TerminalSize {
rows: number;
cols: number;
shouldClaim: boolean;
}
interface TerminalKeyRecord {
@@ -199,15 +198,6 @@ describe("terminal emulator runtime in a real browser", () => {
expect(window.__paseoTerminal?.options.scrollback).toBe(42_000);
});
it("does not claim PTY ownership from passive mount refits", async () => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 720, height: 360 });
await waitFor({ predicate: () => mounted.sizes.length > 0 });
expect(mounted.sizes[0]?.shouldClaim).toBe(false);
});
it("reports a larger PTY size when the terminal container grows", async () => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 360, height: 180 });
@@ -230,7 +220,6 @@ describe("terminal emulator runtime in a real browser", () => {
const grownSize = latestSize(mounted.sizes);
expect(grownSize.cols).toBeGreaterThan(initialSize.cols);
expect(grownSize.rows).toBeGreaterThan(initialSize.rows);
expect(grownSize.shouldClaim).toBe(true);
});
it("refreshes visible rows on a forced same-size resize", async () => {
@@ -86,15 +86,11 @@ interface StubTerminal {
resize?: (cols: number, rows: number) => void;
focus: () => void;
refresh?: (start: number, end: number) => void;
options?: { theme?: unknown; scrollback?: number; fontFamily?: string; fontSize?: number };
options?: { theme?: unknown; scrollback?: number };
rows?: number;
cols?: number;
}
interface RuntimeFitProbe {
fitAndEmitResize: (input?: { force?: boolean; shouldClaim?: boolean }) => void;
}
function createRuntimeWithTerminal(): {
runtime: TerminalEmulatorRuntime;
terminal: StubTerminal & {
@@ -332,13 +328,14 @@ describe("terminal-emulator-runtime", () => {
const runtime = new TerminalEmulatorRuntime();
const fitAndEmitResize = vi.fn();
(runtime as unknown as RuntimeFitProbe).fitAndEmitResize = fitAndEmitResize;
(runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize =
fitAndEmitResize;
runtime.resize();
runtime.resize({ force: true });
expect(fitAndEmitResize).toHaveBeenNthCalledWith(1, undefined);
expect(fitAndEmitResize).toHaveBeenNthCalledWith(2, { force: true });
expect(fitAndEmitResize).toHaveBeenNthCalledWith(1, false);
expect(fitAndEmitResize).toHaveBeenNthCalledWith(2, true);
});
it("updates terminal theme without remounting", () => {
@@ -384,36 +381,12 @@ describe("terminal-emulator-runtime", () => {
expect(refresh).toHaveBeenCalledWith(0, 11);
});
it("updates terminal font without remounting", () => {
it("forces a refit when the page becomes visible again", () => {
const runtime = new TerminalEmulatorRuntime();
const refresh = vi.fn();
const fitAndEmitResize = vi.fn();
const terminal: StubTerminal = {
write: () => {},
reset: () => {},
focus: () => {},
refresh,
options: { fontFamily: "before", fontSize: 13 },
rows: 12,
cols: 40,
};
(runtime as unknown as { terminal: StubTerminal }).terminal = terminal;
(runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize =
fitAndEmitResize;
runtime.setFont({ fontFamily: " Menlo ", fontSize: 18 });
expect(terminal.options?.fontFamily).toBe("Menlo");
expect(terminal.options?.fontSize).toBe(18);
expect(fitAndEmitResize).toHaveBeenCalledWith({ force: true });
expect(refresh).toHaveBeenCalledWith(0, 11);
});
it("passively refits when the page becomes visible again", () => {
const runtime = new TerminalEmulatorRuntime();
const fitAndEmitResize = vi.fn();
(runtime as unknown as RuntimeFitProbe).fitAndEmitResize = fitAndEmitResize;
(globalThis as { document?: { visibilityState?: string } }).document = {
visibilityState: "visible",
};
@@ -424,14 +397,15 @@ describe("terminal-emulator-runtime", () => {
}
).handleVisibilityRestore();
expect(fitAndEmitResize).toHaveBeenCalledWith({ force: true, shouldClaim: false });
expect(fitAndEmitResize).toHaveBeenCalledWith(true);
});
it("does not refit while the page is still hidden", () => {
const runtime = new TerminalEmulatorRuntime();
const fitAndEmitResize = vi.fn();
(runtime as unknown as RuntimeFitProbe).fitAndEmitResize = fitAndEmitResize;
(runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize =
fitAndEmitResize;
(globalThis as { document?: { visibilityState?: string } }).document = {
visibilityState: "hidden",
};
@@ -37,13 +37,11 @@ export interface TerminalEmulatorRuntimeMountInput {
initialSnapshot: TerminalState | null;
scrollback: number;
theme: ITheme;
fontFamily?: string;
fontSize?: number;
}
export interface TerminalEmulatorRuntimeCallbacks {
onInput?: (data: string) => 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;
@@ -70,6 +68,7 @@ interface TerminalEmulatorRuntimeDisposables {
removeWindowFocus: () => void;
removeDocumentVisibilityChange: () => void;
removeVisualViewportResize: () => void;
clearFitInterval: () => void;
clearFitTimeouts: () => void;
removeFontListeners: () => void;
removeTouchListeners: () => void;
@@ -109,7 +108,6 @@ const isAppleHandheld =
});
const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18;
const DEFAULT_TERMINAL_FONT_SIZE = 13;
const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000];
const OUTPUT_OPERATION_TIMEOUT_MS = 5_000;
const EMPTY_TERMINAL_OUTPUT = new Uint8Array(0);
@@ -150,17 +148,6 @@ const DEFAULT_TERMINAL_FONT_FAMILY = [
"monospace",
].join(", ");
function resolveTerminalFontFamily(fontFamily: string | undefined): string {
const trimmed = fontFamily?.trim();
return trimmed && trimmed.length > 0 ? trimmed : DEFAULT_TERMINAL_FONT_FAMILY;
}
function resolveTerminalFontSize(fontSize: number | undefined): number {
return typeof fontSize === "number" && Number.isFinite(fontSize) && fontSize > 0
? fontSize
: DEFAULT_TERMINAL_FONT_SIZE;
}
function withOverviewRulerBorderHidden(theme: ITheme): ITheme {
return {
...theme,
@@ -177,8 +164,7 @@ export class TerminalEmulatorRuntime {
};
private terminal: Terminal | null = null;
private fitAddon: FitAddon | null = null;
private fitAndEmitResize: ((input?: { force?: boolean; shouldClaim?: boolean }) => void) | null =
null;
private fitAndEmitResize: ((force: boolean) => void) | null = null;
private lastSize: { rows: number; cols: number } | null = null;
private cleanup: (() => void) | null = null;
private outputOperations: TerminalOutputOperation[] = [];
@@ -195,10 +181,10 @@ export class TerminalEmulatorRuntime {
return;
}
this.fitAndEmitResize?.({ force: true, shouldClaim: false });
this.fitAndEmitResize?.(true);
if (typeof window.requestAnimationFrame === "function") {
window.requestAnimationFrame(() => {
this.fitAndEmitResize?.({ force: true, shouldClaim: false });
this.fitAndEmitResize?.(true);
});
}
};
@@ -224,8 +210,8 @@ export class TerminalEmulatorRuntime {
convertEol: false,
cursorBlink: true,
cursorStyle: "bar",
fontFamily: resolveTerminalFontFamily(input.fontFamily),
fontSize: resolveTerminalFontSize(input.fontSize),
fontFamily: DEFAULT_TERMINAL_FONT_FAMILY,
fontSize: 13,
lineHeight: 1.0,
macOptionIsMeta: true,
minimumContrastRatio: 1,
@@ -295,7 +281,7 @@ export class TerminalEmulatorRuntime {
webglAddon = null;
disposeImageAddon();
// WebGL and DOM renderers can have different cell dimensions.
this.fitAndEmitResize?.({ force: true, shouldClaim: false });
this.fitAndEmitResize?.(true);
};
// Browser xterm is a renderer only; it never replies to terminal protocol queries.
@@ -333,7 +319,7 @@ export class TerminalEmulatorRuntime {
imageAddon = new ImageAddon();
terminal.loadAddon(imageAddon);
registerProtocolQuerySuppression();
this.fitAndEmitResize?.({ force: true, shouldClaim: false });
this.fitAndEmitResize?.(true);
} catch {
disposeWebglRenderer();
}
@@ -350,9 +336,7 @@ export class TerminalEmulatorRuntime {
this.fitAddon = fitAddon;
window.__paseoTerminal = terminal;
const fitAndEmitResize = (resizeInput?: { force?: boolean; shouldClaim?: boolean }): void => {
const force = resizeInput?.force ?? false;
const shouldClaim = resizeInput?.shouldClaim ?? true;
const fitAndEmitResize = (force: boolean): void => {
const currentTerminal = this.terminal;
const currentFitAddon = this.fitAddon;
if (!currentTerminal || !currentFitAddon) {
@@ -381,12 +365,11 @@ export class TerminalEmulatorRuntime {
this.callbacks.onResize?.({
rows: nextRows,
cols: nextCols,
shouldClaim,
});
};
this.fitAndEmitResize = fitAndEmitResize;
fitAndEmitResize({ force: true, shouldClaim: false });
fitAndEmitResize(true);
const inputDisposable = terminal.onData((data) => {
if (this.suppressInput) {
@@ -471,12 +454,12 @@ export class TerminalEmulatorRuntime {
terminal,
});
const resizeObserver = new ResizeObserver(() => {
fitAndEmitResize({ shouldClaim: true });
fitAndEmitResize(false);
});
resizeObserver.observe(input.root);
resizeObserver.observe(input.host);
const windowResizeHandler = () => fitAndEmitResize({ shouldClaim: true });
const windowResizeHandler = () => fitAndEmitResize(false);
window.addEventListener("resize", windowResizeHandler);
const windowFocusHandler = () => {
this.handleVisibilityRestore();
@@ -489,23 +472,26 @@ export class TerminalEmulatorRuntime {
document.addEventListener("visibilitychange", documentVisibilityChangeHandler);
const visualViewport = window.visualViewport;
const visualViewportResizeHandler = () => fitAndEmitResize({ shouldClaim: true });
const visualViewportResizeHandler = () => fitAndEmitResize(false);
visualViewport?.addEventListener("resize", visualViewportResizeHandler);
const fitInterval = window.setInterval(() => {
fitAndEmitResize(false);
}, 250);
const fitTimeouts = FIT_TIMEOUT_DELAYS_MS.map((delayMs) =>
window.setTimeout(() => {
fitAndEmitResize({ force: true, shouldClaim: false });
fitAndEmitResize(true);
}, delayMs),
);
const fontSet = document.fonts;
const fontReadyHandler = () => {
fitAndEmitResize({ force: true, shouldClaim: false });
fitAndEmitResize(true);
};
fontSet?.addEventListener?.("loadingdone", fontReadyHandler);
void fontSet?.ready
.then(() => {
fitAndEmitResize({ force: true, shouldClaim: false });
fitAndEmitResize(true);
return;
})
.catch(() => {
@@ -513,7 +499,7 @@ export class TerminalEmulatorRuntime {
});
window.setTimeout(() => {
fitAndEmitResize({ force: true, shouldClaim: false });
fitAndEmitResize(true);
}, 0);
if (input.initialSnapshot) {
@@ -541,6 +527,9 @@ export class TerminalEmulatorRuntime {
removeVisualViewportResize: () => {
visualViewport?.removeEventListener("resize", visualViewportResizeHandler);
},
clearFitInterval: () => {
window.clearInterval(fitInterval);
},
clearFitTimeouts: () => {
for (const handle of fitTimeouts) {
window.clearTimeout(handle);
@@ -576,6 +565,7 @@ export class TerminalEmulatorRuntime {
disposables.removeWindowFocus();
disposables.removeDocumentVisibilityChange();
disposables.removeVisualViewportResize();
disposables.clearFitInterval();
disposables.clearFitTimeouts();
disposables.removeFontListeners();
disposables.removeTouchListeners();
@@ -645,8 +635,8 @@ export class TerminalEmulatorRuntime {
this.processOutputQueue();
}
resize(input?: { force?: boolean; shouldClaim?: boolean }): void {
this.fitAndEmitResize?.(input);
resize(input?: { force?: boolean }): void {
this.fitAndEmitResize?.(input?.force ?? false);
}
setTheme(input: { theme: ITheme }): void {
@@ -682,24 +672,6 @@ export class TerminalEmulatorRuntime {
this.refreshVisibleRows();
}
setFont(input: { fontFamily?: string; fontSize?: number }): void {
const terminal = this.terminal;
if (!terminal) {
return;
}
try {
terminal.options.fontFamily = resolveTerminalFontFamily(input.fontFamily);
terminal.options.fontSize = resolveTerminalFontSize(input.fontSize);
} catch {
// ignore
return;
}
this.fitAndEmitResize?.({ force: true });
this.refreshVisibleRows();
}
focus(input?: { forceRefocus?: boolean }): void {
const terminal = this.terminal;
if (!terminal) {
@@ -1,182 +0,0 @@
import { page } from "@vitest/browser/context";
import { afterEach, describe, expect, it } from "vitest";
import type { TerminalState } from "@getpaseo/protocol/messages";
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
// Regression: "streaming pino log, resized the Paseo terminal, old logs stayed
// narrow and new logs drew on top of the old ones."
//
// A heavy stream overflows MAX_TERMINAL_OUTPUT_FRAME_BYTES, so the server sends a
// SNAPSHOT mid-stream. The client restores it via renderTerminalSnapshotToAnsi.
// When the snapshot carries per-row soft-wrap flags, restored long lines must
// reflow on resize just like live output — not freeze at the snapshot width.
interface Mounted {
host: HTMLDivElement;
root: HTMLDivElement;
runtime: TerminalEmulatorRuntime;
terminal: InspectableTerminal;
}
interface InspectableTerminal {
cols: number;
buffer: {
active: {
length: number;
getLine: (
index: number,
) => { translateToString: (trimRight: boolean) => string; isWrapped: boolean } | undefined;
};
};
}
const mounted: Mounted[] = [];
function nextFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise<void> {
const start = performance.now();
while (!predicate()) {
if (performance.now() - start > timeoutMs) throw new Error("timeout waiting for condition");
await nextFrame();
}
}
function mount(width: number, height: number): Mounted {
const root = document.createElement("div");
root.style.cssText = `width:${width}px;height:${height}px;position:fixed;left:0;top:0;overflow:hidden`;
const host = document.createElement("div");
host.style.cssText = "width:100%;height:100%";
root.appendChild(host);
document.body.appendChild(root);
const runtime = new TerminalEmulatorRuntime();
runtime.setCallbacks({ callbacks: {} });
runtime.mount({
root,
host,
initialSnapshot: null,
scrollback: 10_000,
theme: { background: "#0b0b0b", foreground: "#e6e6e6", cursor: "#e6e6e6" },
});
const terminal = window.__paseoTerminal as InspectableTerminal | undefined;
if (!terminal) {
throw new Error("terminal did not mount");
}
const m = { host, root, runtime, terminal };
mounted.push(m);
return m;
}
interface Row {
text: string;
wrapped: boolean;
}
function dumpRows(term: InspectableTerminal): Row[] {
const buf = term.buffer.active;
const rows: Row[] = [];
for (let i = 0; i < buf.length; i++) {
const line = buf.getLine(i);
if (!line) continue;
rows.push({ text: line.translateToString(true).trimEnd(), wrapped: line.isWrapped });
}
return rows;
}
function someRowContains(term: InspectableTerminal, text: string): boolean {
return dumpRows(term).some((r) => r.text.includes(text));
}
function renderSnapshotCommitted(runtime: TerminalEmulatorRuntime, state: TerminalState) {
return new Promise<void>((resolve) => {
runtime.renderSnapshot({ state, onCommitted: resolve });
});
}
function writeCommitted(runtime: TerminalEmulatorRuntime, data: Uint8Array) {
return new Promise<void>((resolve) => {
runtime.write({ data, onCommitted: resolve });
});
}
// Build a server-style snapshot of one long pino line that the SERVER soft-wrapped
// at `cols`, represented as multiple grid rows (exactly what the daemon stores in
// its headless xterm and ships in a snapshot).
function buildWrappedSnapshot(cols: number): TerminalState {
const longLine = `[server] [15:30:00.123] TRACE: provider.claude.raw_event {"module":"agent","seq":1,"sessionId":"ed8972f4-d3be-45d0-992c-631f8f1ed04e","turnId":"foreground-turn-1","payload":"${"A".repeat(40)}"}`;
const grid: TerminalState["grid"] = [];
for (let i = 0; i < longLine.length; i += cols) {
const chunk = longLine.slice(i, i + cols);
grid.push([...chunk].map((char) => ({ char })));
}
// The fixed daemon ships per-row soft-wrap flags: every row of the logical line
// continues onto the next except the last.
const gridWrapped = grid.map((_, index) => index < grid.length - 1);
// Cursor sits just below the restored content, exactly where the daemon's
// headless terminal leaves it after a line break, so streamed output appends
// cleanly below instead of overwriting the restored rows.
return {
rows: 24,
cols,
scrollback: [],
scrollbackWrapped: [],
grid,
gridWrapped,
cursor: { row: grid.length, col: 0 },
};
}
function pinoLine(seq: number): string {
return `[server] [15:30:${String(seq % 60).padStart(2, "0")}.123] TRACE: provider.claude.raw_event {"module":"agent","seq":${seq},"sessionId":"ed8972f4-d3be-45d0-992c-631f8f1ed04e","turnId":"foreground-turn-1","payload":"${"A".repeat(40)}"}\r\n`;
}
afterEach(() => {
for (const m of mounted.splice(0)) {
m.runtime.unmount();
m.root.remove();
}
});
describe("terminal resize reflow repro (Paseo terminal)", () => {
it("snapshot-restored rows stay frozen at the snapshot width after the terminal grows", async () => {
await page.viewport(1600, 700);
const m = mount(560, 360); // ~70 cols
await waitFor(() => m.terminal.cols > 40);
const narrowCols = m.terminal.cols;
// 1) Mid-stream snapshot arrives (server overflowed 256KB). It carries the
// long line wrapped at the server width as separate grid rows.
await renderSnapshotCommitted(m.runtime, buildWrappedSnapshot(narrowCols));
await waitFor(() => someRowContains(m.terminal, "[15:30:00.123]"));
// 2) Normal post-snapshot streaming resumes (autowrap on -> soft-wrapped).
for (let seq = 90; seq <= 96; seq++) {
await writeCommitted(m.runtime, encodeTerminalOutput(pinoLine(seq)));
}
await waitFor(() => someRowContains(m.terminal, "[15:30:36.123]"));
// 3) User resizes the terminal wider.
m.root.style.width = "1480px";
await nextFrame();
m.runtime.resize({ force: true, shouldClaim: true });
await nextFrame();
await nextFrame();
const wideCols = m.terminal.cols;
const rows = dumpRows(m.terminal);
const snapshotRowLen =
rows.find((r) => r.text.startsWith("[server] [15:30:00"))?.text.length ?? -1;
const streamedRowLen =
rows.find((r) => r.text.startsWith("[server] [15:30:30"))?.text.length ?? -1;
expect(wideCols).toBeGreaterThan(narrowCols + 20);
// Post-snapshot streamed output reflows to fill the wide terminal.
expect(streamedRowLen).toBeGreaterThan(narrowCols + 20);
// Snapshot-restored content reflows too, instead of staying frozen at the
// snapshot width — the reported bug.
expect(snapshotRowLen).toBeGreaterThan(narrowCols + 20);
});
});
@@ -18,8 +18,6 @@ interface MountMessage {
initialSnapshot: TerminalState | null;
scrollbackLines: number;
theme: ITheme;
fontFamily?: string;
fontSize?: number;
pendingModifiers: PendingTerminalModifiers;
swipeGesturesEnabled: boolean;
}
@@ -32,10 +30,9 @@ type InboundMessage =
| { 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 }
| {
@@ -49,7 +46,7 @@ type OutboundMessage =
| { 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;
@@ -230,8 +227,6 @@ class TerminalWebViewBridge {
MountMessage | { type: "unmount" } | { type: "resolveLocalFileLinkResponse" }
>,
): void {
if (this.receiveConfigurationMessage(message)) return;
switch (message.type) {
case "writeOutput":
this.runtime?.write({ data: encodeTerminalOutput(message.text) });
@@ -249,36 +244,21 @@ class TerminalWebViewBridge {
this.runtime?.focus({ forceRefocus: message.forceRefocus });
break;
case "resize":
this.runtime?.resize({ force: true, shouldClaim: message.shouldClaim !== false });
this.runtime?.resize({ force: true });
break;
}
}
private receiveConfigurationMessage(
message: Exclude<
InboundMessage,
MountMessage | { type: "unmount" } | { type: "resolveLocalFileLinkResponse" }
>,
): boolean {
switch (message.type) {
case "setTheme":
this.applyThemeBackground(message.theme);
this.runtime?.setTheme({ theme: message.theme });
return true;
break;
case "setScrollback":
this.runtime?.setScrollback({ lines: message.lines });
return true;
case "setFont":
this.runtime?.setFont({ fontFamily: message.fontFamily, fontSize: message.fontSize });
return true;
break;
case "setPendingModifiers":
this.runtime?.setPendingModifiers({ pendingModifiers: message.pendingModifiers });
return true;
break;
case "setSwipeGesturesEnabled":
this.swipeGesturesEnabled = message.enabled;
return true;
default:
return false;
break;
}
}
@@ -293,8 +273,8 @@ class TerminalWebViewBridge {
runtime.setCallbacks({
callbacks: {
onInput: (data) => sendToNative({ type: "input", streamKey: message.streamKey, data }),
onResize: ({ rows, cols, shouldClaim }) =>
sendToNative({ type: "resize", streamKey: message.streamKey, rows, cols, shouldClaim }),
onResize: ({ rows, cols }) =>
sendToNative({ type: "resize", streamKey: message.streamKey, rows, cols }),
onTerminalKey: (input) =>
sendToNative({ type: "terminalKey", streamKey: message.streamKey, ...input }),
onPendingModifiersConsumed: () =>
@@ -320,8 +300,6 @@ class TerminalWebViewBridge {
initialSnapshot: message.initialSnapshot,
scrollback: message.scrollbackLines,
theme: message.theme,
fontFamily: message.fontFamily,
fontSize: message.fontSize,
});
sendToNative({ type: "rendererReady", streamKey: message.streamKey, isReady: true });
}
File diff suppressed because one or more lines are too long

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