mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5aecd36194 | ||
|
|
539441b3d5 | ||
|
|
4b9eff1759 | ||
|
|
48bc2f3166 | ||
|
|
14af053c66 | ||
|
|
4f3116d28a | ||
|
|
5666b3014a | ||
|
|
07ace19b69 | ||
|
|
f1a20a9fe9 | ||
|
|
eac5d93f78 | ||
|
|
541e4c04cf | ||
|
|
ce8ad8c68f | ||
|
|
1ede1bccf7 | ||
|
|
a5c2f39f65 | ||
|
|
a807eb0eb2 | ||
|
|
d406c29e16 | ||
|
|
8c6abcb41f | ||
|
|
b8ccd543c4 | ||
|
|
ae5dfc0b3c | ||
|
|
539d2969f1 | ||
|
|
7fb3dda20c | ||
|
|
89ec358d00 | ||
|
|
0efa0f3c42 | ||
|
|
13a1d5ffba | ||
|
|
f9f6ff2dbc | ||
|
|
3095bcb760 | ||
|
|
eb7495d231 | ||
|
|
357c31d16e | ||
|
|
a3b0af9ac0 | ||
|
|
632c48fde3 | ||
|
|
922a93af2f | ||
|
|
2227540fad | ||
|
|
360110accc | ||
|
|
6260224514 | ||
|
|
1821f2853a | ||
|
|
c60bc9f5ae | ||
|
|
9f88b4e69c | ||
|
|
4cc72a84e8 | ||
|
|
794cbcef63 | ||
|
|
a1c8e1a1f9 | ||
|
|
f26a81798b | ||
|
|
67133e3ebc | ||
|
|
d1f37b5d72 | ||
|
|
8cf80c2486 | ||
|
|
3d96ea6354 | ||
|
|
0e3eb9cf75 | ||
|
|
c8ca503cc3 | ||
|
|
41cb1af036 | ||
|
|
17aa957d3e | ||
|
|
2c756e9a8a |
52
CHANGELOG.md
52
CHANGELOG.md
@@ -1,5 +1,57 @@
|
||||
# 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
|
||||
|
||||
@@ -36,6 +36,7 @@ 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 |
|
||||
|
||||
@@ -14,14 +14,14 @@ Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `
|
||||
|
||||
## Relationships
|
||||
|
||||
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`.
|
||||
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`.
|
||||
|
||||
There is exactly one relationship type today: `parentAgentId`. The daemon does not distinguish between:
|
||||
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`.
|
||||
|
||||
- **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)
|
||||
- **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.
|
||||
|
||||
Both look the same in storage. This is an accepted limitation — see [Limitations](#limitations).
|
||||
`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.
|
||||
|
||||
## Archive
|
||||
|
||||
@@ -77,12 +77,6 @@ We considered universal decoupling (no tab close ever archives, archive is alway
|
||||
|
||||
## Limitations
|
||||
|
||||
### Detached agents are cascade-archived
|
||||
|
||||
The daemon can't tell a "subagent" apart from a "detached agent" — both carry `paseo.parent-agent-id`. So when you archive an agent that previously launched a detached child (e.g. via `/paseo-handoff`), cascade will archive the detached child too, even though semantically it should outlive the originator.
|
||||
|
||||
Until a richer relation model lands (e.g. a `relation: "subagent" | "detached"` field on creation, or a separate channel for handoff launches), this trade-off stands. Workaround: don't archive an agent whose work was handed off, or unarchive the detached child afterward.
|
||||
|
||||
### Subagent accumulation under long-lived parents
|
||||
|
||||
A parent that spawns many subagents will see the track grow. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem.
|
||||
@@ -99,11 +93,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 `create_agent` MCP tool |
|
||||
| `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 agent-scoped `create_agent` unless `detached: true` |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
|
||||
See [`docs/data-model.md`](./data-model.md) for the full agent record.
|
||||
|
||||
@@ -183,6 +183,8 @@ 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
|
||||
|
||||
@@ -147,6 +147,9 @@ 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 }
|
||||
@@ -206,7 +209,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 }` — cron expression
|
||||
- `{ 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
|
||||
|
||||
### Nested: ScheduleTarget (discriminated union on `type`)
|
||||
|
||||
|
||||
@@ -43,6 +43,70 @@ 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
|
||||
@@ -59,6 +123,12 @@ 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
|
||||
@@ -88,12 +158,14 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
|
||||
|
||||
| Variable | Value |
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PASEO_SERVICE_<NAME>_URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
|
||||
| `PASEO_SERVICE_<NAME>_URL` | Proxied 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:
|
||||
|
||||
@@ -18,7 +18,8 @@ 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 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).
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -191,6 +191,9 @@ 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
|
||||
@@ -198,35 +201,52 @@ 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`).
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Babysitting mobile after a release
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
Pattern:
|
||||
|
||||
```jsonc
|
||||
// mcp__paseo__create_schedule arguments
|
||||
// mcp__paseo__create_heartbeat arguments
|
||||
{
|
||||
"name": "vX.Y.Z release babysit heartbeat",
|
||||
"every": "15m",
|
||||
"cron": "*/15 * * * *",
|
||||
"maxRuns": 8, // covers ~2h of build + store-submission window
|
||||
"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.",
|
||||
"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.",
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Release notes on GitHub
|
||||
|
||||
@@ -300,8 +320,8 @@ No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to
|
||||
|
||||
## Changelog ownership
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## Changelog voice
|
||||
|
||||
@@ -380,19 +400,15 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
|
||||
|
||||
## Pre-release sanity check
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
|
||||
Review the diff between the latest release tag and `HEAD`. Focus on:
|
||||
|
||||
> 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`
|
||||
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.
|
||||
|
||||
The agent's job is a deep sanity check, not a full code review. If it flags anything, investigate before proceeding.
|
||||
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.
|
||||
|
||||
## Changelog scope
|
||||
|
||||
@@ -417,5 +433,9 @@ 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 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
|
||||
- [ ] 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
|
||||
|
||||
92
docs/service-proxy.md
Normal file
92
docs/service-proxy.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# 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,8 +2,8 @@
|
||||
|
||||
Agent chat delivery has two paths:
|
||||
|
||||
1. **Live stream** — `agent_stream` WebSocket messages for immediacy.
|
||||
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness.
|
||||
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.
|
||||
|
||||
The invariant is:
|
||||
|
||||
@@ -24,6 +24,8 @@ 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.
|
||||
|
||||
@@ -289,6 +289,21 @@ 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 @@
|
||||
sha256-B6xcv2QfqpmvZCwYZ34pF2HWu3Icjl3bylHcQr8lo8g=
|
||||
sha256-R2EUXh8wV0bjdeDNWMhFzd3iDV/dpkLUikbX7nzoMXQ=
|
||||
|
||||
112
package-lock.json
generated
112
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.87",
|
||||
"version": "0.1.89",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.87",
|
||||
"version": "0.1.89",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -2813,6 +2813,50 @@
|
||||
"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",
|
||||
@@ -7874,6 +7918,12 @@
|
||||
"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",
|
||||
@@ -16979,6 +17029,12 @@
|
||||
"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",
|
||||
@@ -34033,6 +34089,12 @@
|
||||
"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",
|
||||
@@ -36027,6 +36089,12 @@
|
||||
"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",
|
||||
@@ -36885,7 +36953,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.87",
|
||||
"version": "0.1.89",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -37110,12 +37178,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.87",
|
||||
"version": "0.1.89",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.1.87",
|
||||
"@getpaseo/protocol": "0.1.87",
|
||||
"@getpaseo/server": "0.1.87",
|
||||
"@getpaseo/client": "0.1.89",
|
||||
"@getpaseo/protocol": "0.1.89",
|
||||
"@getpaseo/server": "0.1.89",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37361,10 +37429,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.1.87",
|
||||
"version": "0.1.89",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.1.87",
|
||||
"@getpaseo/relay": "0.1.87",
|
||||
"@getpaseo/protocol": "0.1.89",
|
||||
"@getpaseo/relay": "0.1.89",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37384,7 +37452,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.87",
|
||||
"version": "0.1.89",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37636,7 +37704,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.87",
|
||||
"version": "0.1.89",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.25",
|
||||
@@ -37672,8 +37740,10 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.87",
|
||||
"version": "0.1.89",
|
||||
"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",
|
||||
@@ -37901,7 +37971,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.1.87",
|
||||
"version": "0.1.89",
|
||||
"dependencies": {
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -37922,7 +37992,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.87",
|
||||
"version": "0.1.89",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38140,14 +38210,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.87",
|
||||
"version": "0.1.89",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@getpaseo/client": "0.1.87",
|
||||
"@getpaseo/highlight": "0.1.87",
|
||||
"@getpaseo/protocol": "0.1.87",
|
||||
"@getpaseo/relay": "0.1.87",
|
||||
"@getpaseo/client": "0.1.89",
|
||||
"@getpaseo/highlight": "0.1.89",
|
||||
"@getpaseo/protocol": "0.1.89",
|
||||
"@getpaseo/relay": "0.1.89",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38919,7 +38989,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.87",
|
||||
"version": "0.1.89",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.87",
|
||||
"version": "0.1.89",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
installAcpCatalogProvider,
|
||||
openAddProviderModal,
|
||||
openSettingsHost,
|
||||
openSettingsHostSection,
|
||||
} from "./helpers/settings";
|
||||
|
||||
const ACP_PROVIDER = {
|
||||
@@ -18,6 +19,8 @@ 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);
|
||||
|
||||
@@ -36,18 +36,49 @@ function bottomSheetBackdrop(page: Page) {
|
||||
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
||||
}
|
||||
|
||||
function bottomSheetHandle(page: Page) {
|
||||
return page.getByRole("slider", { name: "Bottom sheet handle" }).first();
|
||||
}
|
||||
|
||||
async function expectBottomSheetOpen(page: Page) {
|
||||
await expect(bottomSheetBackdrop(page)).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function closeBottomSheetWithBackdrop(page: Page) {
|
||||
const box = await bottomSheetBackdrop(page).boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
await page.mouse.click(box!.x + box!.width / 2, box!.y + 24);
|
||||
await expect(bottomSheetBackdrop(page)).not.toBeVisible({ timeout: 10_000 });
|
||||
const backdrop = bottomSheetBackdrop(page);
|
||||
const handle = bottomSheetHandle(page);
|
||||
// Tapping the backdrop is the close path under test, but on a loaded CI runner
|
||||
// the model-selector sheet re-renders as its model list settles and Gorhom
|
||||
// drops backdrop presses during that churn — so a tap (even retried) can fail
|
||||
// to dismiss. Tap the backdrop first; if it survives, drag the handle down,
|
||||
// which drives Gorhom's pan-to-close directly and is unaffected by the churn.
|
||||
// The post-close guard below still protects the regression this test exists
|
||||
// for: a sheet that dismisses, then re-presents.
|
||||
await expect(async () => {
|
||||
if (!(await backdrop.isVisible())) {
|
||||
return;
|
||||
}
|
||||
const box = await backdrop.boundingBox();
|
||||
if (box) {
|
||||
await page.mouse.click(box.x + box.width / 2, box.y + 24);
|
||||
}
|
||||
await page.waitForTimeout(150);
|
||||
if (await backdrop.isVisible()) {
|
||||
const handleBox = await handle.boundingBox();
|
||||
if (handleBox) {
|
||||
const startX = handleBox.x + handleBox.width / 2;
|
||||
const startY = handleBox.y + handleBox.height / 2;
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(startX, startY + 400, { steps: 8 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
}
|
||||
await expect(backdrop).not.toBeVisible({ timeout: 1_000 });
|
||||
}).toPass({ timeout: 15_000 });
|
||||
// Guard against the regression where the sheet starts dismissing, then re-presents.
|
||||
await page.waitForTimeout(500);
|
||||
await expect(bottomSheetBackdrop(page)).not.toBeVisible({ timeout: 1_000 });
|
||||
await expect(backdrop).not.toBeVisible({ timeout: 1_000 });
|
||||
}
|
||||
|
||||
async function openTabSwitcher(page: Page) {
|
||||
|
||||
@@ -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 } from "./settings";
|
||||
import { openSettingsHost, openSettingsHostSection } from "./settings";
|
||||
|
||||
interface DaemonApiStatus {
|
||||
version: string;
|
||||
@@ -208,6 +208,9 @@ 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,
|
||||
});
|
||||
|
||||
@@ -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?: "canonical"; limit?: number },
|
||||
options?: { direction?: "head" | "tail"; projection?: "projected"; limit?: number },
|
||||
) => Promise<{ epoch?: string }>;
|
||||
};
|
||||
const timeline = await client.fetchAgentTimeline(handle.agentId, {
|
||||
direction: "tail",
|
||||
projection: "canonical",
|
||||
projection: "projected",
|
||||
limit: 0,
|
||||
});
|
||||
return timeline.epoch;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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",
|
||||
@@ -13,6 +15,8 @@ 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();
|
||||
@@ -28,8 +32,22 @@ export async function openSettingsSection(page: Page, section: SettingsSection):
|
||||
}
|
||||
|
||||
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
|
||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
// 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}$`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
|
||||
@@ -37,6 +55,9 @@ 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();
|
||||
}
|
||||
@@ -68,7 +89,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.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
|
||||
await expect(page.getByTestId("host-page-connections-card")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectSettingsSidebarVisible(page: Page): Promise<void> {
|
||||
@@ -105,7 +126,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))}$`),
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}/connections$`),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,7 +181,11 @@ export async function expectAboutContent(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
export async function expectGeneralContent(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
|
||||
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();
|
||||
}
|
||||
|
||||
export async function expectHostLabelDisplayed(page: Page): Promise<void> {
|
||||
@@ -181,7 +206,11 @@ 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();
|
||||
await expect(page.getByText("Connections", { exact: true })).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(
|
||||
card.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
|
||||
).toBeVisible();
|
||||
@@ -193,14 +222,29 @@ export async function expectHostInjectMcpCard(page: Page): Promise<void> {
|
||||
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostActionCards(page: Page): Promise<void> {
|
||||
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");
|
||||
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({
|
||||
@@ -244,27 +288,35 @@ 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();
|
||||
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);
|
||||
|
||||
// App group rows remain top-level.
|
||||
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(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
export async function expectHostPageVisible(page: Page, _serverId: string): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-connections-card")).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 });
|
||||
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();
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ import { getServerId } from "./helpers/server-id";
|
||||
import {
|
||||
expectSettingsHeader,
|
||||
openSettingsHost,
|
||||
openHostSection,
|
||||
expectHostLabelDisplayed,
|
||||
clickEditHostLabel,
|
||||
expectHostLabelEditMode,
|
||||
expectHostConnectionsCard,
|
||||
expectHostInjectMcpCard,
|
||||
expectHostActionCards,
|
||||
expectHostProvidersCard,
|
||||
expectHostNoLocalOnlyRows,
|
||||
expectRetiredSidebarSectionsAbsent,
|
||||
expectHostPageVisible,
|
||||
@@ -19,9 +21,7 @@ import {
|
||||
} from "./helpers/settings";
|
||||
|
||||
test.describe("Settings host page", () => {
|
||||
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("connections section shows the seeded connection endpoint", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const port = getE2EDaemonPort();
|
||||
|
||||
@@ -29,11 +29,44 @@ test.describe("Settings host page", () => {
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
await expectSettingsHeader(page, TEST_HOST_LABEL);
|
||||
await expectHostLabelDisplayed(page);
|
||||
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);
|
||||
await expectHostActionCards(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 expectHostLabelDisplayed(page);
|
||||
await expectHostActionCards(page, serverId);
|
||||
});
|
||||
|
||||
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
|
||||
@@ -42,13 +75,14 @@ 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("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
|
||||
test("daemon section does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
@@ -56,19 +90,20 @@ 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 does not expose retired top-level sections", async ({ page }) => {
|
||||
test("settings sidebar exposes the flat App and Host section rows", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
await expectRetiredSidebarSectionsAbsent(page);
|
||||
});
|
||||
|
||||
test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({
|
||||
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
@@ -77,9 +112,10 @@ test.describe("Settings host page", () => {
|
||||
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
|
||||
|
||||
await expectHostPageVisible(page, serverId);
|
||||
await expectSettingsHeader(page, TEST_HOST_LABEL);
|
||||
await expectSettingsHeader(page, "Connections");
|
||||
await openHostSection(page, serverId, "daemon");
|
||||
await expectHostLabelDisplayed(page);
|
||||
await expectHostActionCards(page);
|
||||
await expectHostActionCards(page, serverId);
|
||||
});
|
||||
|
||||
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
expectDiagnosticsContent,
|
||||
expectAboutContent,
|
||||
expectGeneralContent,
|
||||
expectAppearanceContent,
|
||||
} from "./helpers/settings";
|
||||
|
||||
test.describe("Settings sidebar navigation", () => {
|
||||
@@ -43,9 +44,13 @@ 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 /settings/hosts/[serverId]", async ({ page }) => {
|
||||
test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await verifyLegacyHostSettingsRedirect(page);
|
||||
});
|
||||
@@ -132,7 +137,9 @@ test.describe("Settings — compact master-detail", () => {
|
||||
await expectSettingsBackButton(page);
|
||||
});
|
||||
|
||||
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
|
||||
test("tapping a host section row pushes /settings/hosts/[serverId]/connections", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
|
||||
|
||||
119
packages/app/e2e/terminal-split-resize.spec.ts
Normal file
119
packages/app/e2e/terminal-split-resize.spec.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
80
packages/app/e2e/workspace-scripts-menu-resize.spec.ts
Normal file
80
packages/app/e2e/workspace-scripts-menu-resize.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
createWorkspaceThroughDaemon,
|
||||
openWorkspaceScriptsMenu,
|
||||
seedProjectForWorkspaceSetup,
|
||||
startWorkspaceScriptFromMenu,
|
||||
waitForWorkspaceSetupProgress,
|
||||
} from "./helpers/workspace-setup";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { buildHostWorkspaceRoute } from "../src/utils/host-routes";
|
||||
|
||||
test("scripts menu resizes when a service row grows after launch", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("script-menu-resize-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["sh -c 'echo setup complete'"],
|
||||
},
|
||||
scripts: {
|
||||
web: {
|
||||
type: "service",
|
||||
command:
|
||||
"node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, () => console.log('listening on ' + s.address().port))\"",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: `script-menu-resize-${Date.now()}`,
|
||||
});
|
||||
await completed;
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openWorkspaceScriptsMenu(page);
|
||||
|
||||
const menu = page.getByTestId("workspace-scripts-menu");
|
||||
const before = await menu.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
height: rect.height,
|
||||
clientHeight: element.clientHeight,
|
||||
scrollHeight: element.scrollHeight,
|
||||
};
|
||||
});
|
||||
|
||||
await startWorkspaceScriptFromMenu(page, "web");
|
||||
await expect(menu).toContainText("localhost:", { timeout: 15_000 });
|
||||
|
||||
const after = await menu.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const firstChild = element.firstElementChild;
|
||||
const childRect = firstChild?.getBoundingClientRect();
|
||||
return {
|
||||
height: rect.height,
|
||||
clientHeight: element.clientHeight,
|
||||
scrollHeight: element.scrollHeight,
|
||||
childHeight: childRect?.height ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
expect(after.height).toBeGreaterThan(before.height);
|
||||
expect(after.scrollHeight).toBeLessThanOrEqual(after.clientHeight + 1);
|
||||
expect(after.childHeight).toBeGreaterThan(before.height);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -74,23 +74,6 @@ 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,
|
||||
@@ -98,10 +81,13 @@ platform :ios do
|
||||
build_number: build_number.to_s,
|
||||
submit_for_review: true,
|
||||
automatic_release: true,
|
||||
reject_if_possible: true,
|
||||
release_notes: {
|
||||
"en-US" => "Bug fixes and improvements.",
|
||||
},
|
||||
force: true,
|
||||
skip_binary_upload: true,
|
||||
skip_screenshots: true,
|
||||
skip_metadata: true,
|
||||
precheck_include_in_app_purchases: false,
|
||||
submission_information: {
|
||||
add_id_info_uses_idfa: false,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.87",
|
||||
"version": "0.1.89",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"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": "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,6 +26,7 @@
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"build": "npm run build:web",
|
||||
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
|
||||
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
|
||||
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
|
||||
},
|
||||
|
||||
@@ -17,14 +17,18 @@
|
||||
<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">
|
||||
/* These styles make the body full-height */
|
||||
/* Keep the app shell fixed to the viewport. */
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
/* These styles disable body scrolling if you are using <ScrollView> */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
/* These styles smooth text rendering in the app shell. */
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -38,8 +42,12 @@
|
||||
/* 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>
|
||||
|
||||
172
packages/app/scripts/profile-workspace-tabs.mjs
Normal file
172
packages/app/scripts/profile-workspace-tabs.mjs
Normal file
@@ -0,0 +1,172 @@
|
||||
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();
|
||||
@@ -41,6 +41,7 @@ import { PlanCard } from "@/components/plan-card";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentPermissionAction,
|
||||
AgentPermissionResponse,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
@@ -77,6 +78,7 @@ 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;
|
||||
@@ -214,6 +216,18 @@ 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>(
|
||||
@@ -745,7 +759,68 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
},
|
||||
);
|
||||
|
||||
export const AgentStreamView = memo(AgentStreamViewComponent);
|
||||
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);
|
||||
AgentStreamView.displayName = "AgentStreamView";
|
||||
|
||||
interface ToolCallSlotProps extends Omit<
|
||||
|
||||
@@ -73,6 +73,7 @@ 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";
|
||||
@@ -625,6 +626,27 @@ 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} />
|
||||
@@ -867,7 +889,8 @@ 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]" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/index" />
|
||||
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +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";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
13
packages/app/src/app/settings/hosts/[serverId]/index.tsx
Normal file
13
packages/app/src/app/settings/hosts/[serverId]/index.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
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")} />;
|
||||
}
|
||||
@@ -9,9 +9,11 @@ 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";
|
||||
@@ -19,10 +21,16 @@ import { useFileLink } from "./use-file-link";
|
||||
interface AssistantMarkdownLinkProps {
|
||||
source: AssistantFileLinkSource;
|
||||
style: StyleProp<TextStyle>;
|
||||
monoSurface?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AssistantMarkdownLink({ source, style, children }: AssistantMarkdownLinkProps) {
|
||||
export function AssistantMarkdownLink({
|
||||
source,
|
||||
style,
|
||||
monoSurface,
|
||||
children,
|
||||
}: AssistantMarkdownLinkProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source);
|
||||
const { configRef } = useAssistantFileLinkResolverContext();
|
||||
@@ -50,11 +58,21 @@ export function AssistantMarkdownLink({ source, style, children }: AssistantMark
|
||||
);
|
||||
|
||||
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}>
|
||||
<Text accessibilityRole="link" onPress={onPress} style={style}>
|
||||
<MarkdownTextSpan
|
||||
accessibilityRole="link"
|
||||
monoSurface={monoSurface}
|
||||
onPress={onPress}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
</MarkdownTextSpan>
|
||||
</FileLinkHoverTooltip>
|
||||
);
|
||||
}
|
||||
@@ -72,7 +90,9 @@ export function AssistantMarkdownLink({ source, style, children }: AssistantMark
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
>
|
||||
<Text style={hoveredTextStyle}>{children}</Text>
|
||||
<Text dataSet={monoSurface ? CODE_SURFACE_DATASET : undefined} style={hoveredTextStyle}>
|
||||
{children}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</a>
|
||||
);
|
||||
@@ -100,7 +120,7 @@ export function AssistantMarkdownCodeLink({
|
||||
[inheritedStyles, codeInlineStyle, linkStyle],
|
||||
);
|
||||
return (
|
||||
<AssistantMarkdownLink source={source} style={style}>
|
||||
<AssistantMarkdownLink source={source} style={style} monoSurface>
|
||||
{children}
|
||||
</AssistantMarkdownLink>
|
||||
);
|
||||
|
||||
66
packages/app/src/components/appearance-style-boundary.tsx
Normal file
66
packages/app/src/components/appearance-style-boundary.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
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,13 +7,19 @@ 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;
|
||||
@@ -72,6 +78,7 @@ export function ContextWindowMeter({
|
||||
maxTokens,
|
||||
usedTokens,
|
||||
totalCostUsd,
|
||||
showPercentage = false,
|
||||
}: ContextWindowMeterProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const percentage = getUsagePercentage(maxTokens, usedTokens);
|
||||
@@ -82,47 +89,56 @@ export function ContextWindowMeter({
|
||||
|
||||
const clampedPercentage = clampPercentage(percentage);
|
||||
const roundedPercentage = Math.round(percentage);
|
||||
const dashOffset = CIRCUMFERENCE - (clampedPercentage / 100) * CIRCUMFERENCE;
|
||||
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 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={styles.container}
|
||||
style={containerStyle}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={`Context window ${roundedPercentage}% used`}
|
||||
>
|
||||
<Svg
|
||||
width={SVG_SIZE}
|
||||
height={SVG_SIZE}
|
||||
viewBox={`0 0 ${SVG_SIZE} ${SVG_SIZE}`}
|
||||
width={svgSize}
|
||||
height={svgSize}
|
||||
viewBox={`0 0 ${svgSize} ${svgSize}`}
|
||||
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={STROKE_WIDTH}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<Circle
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
r={RADIUS}
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={colors.progress}
|
||||
strokeWidth={STROKE_WIDTH}
|
||||
strokeWidth={strokeWidth}
|
||||
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}>
|
||||
@@ -149,9 +165,22 @@ 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],
|
||||
},
|
||||
|
||||
@@ -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}>
|
||||
<View style={linesContainerStyle} dataSet={CODE_SURFACE_DATASET}>
|
||||
{keyedDiffLines.map(({ key, line }) => (
|
||||
<DiffLineRow key={key} line={line} />
|
||||
))}
|
||||
@@ -226,7 +226,7 @@ const styles = StyleSheet.create((theme) => {
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
lineText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
...(isWeb
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useRef, type ReactElement } from "react";
|
||||
import { memo, 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 SortableItem<T>({
|
||||
function SortableItemInner<T>({
|
||||
id,
|
||||
item,
|
||||
index,
|
||||
@@ -118,6 +118,8 @@ function SortableItem<T>({
|
||||
);
|
||||
}
|
||||
|
||||
const SortableItem = memo(SortableItemInner) as typeof SortableItemInner;
|
||||
|
||||
export function DraggableList<T>({
|
||||
data,
|
||||
keyExtractor,
|
||||
|
||||
@@ -14,7 +14,6 @@ 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 {
|
||||
@@ -1157,7 +1156,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
codeText: {
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
flexShrink: 0,
|
||||
},
|
||||
|
||||
@@ -11,7 +11,6 @@ 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";
|
||||
@@ -19,6 +18,7 @@ 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: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: theme.fontSize.code * 1.45,
|
||||
opacity: 0.4,
|
||||
userSelect: "none",
|
||||
},
|
||||
lineText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.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>
|
||||
<View dataSet={CODE_SURFACE_DATASET}>
|
||||
{keyedLines.map(({ key, tokens, lineNumber }) => (
|
||||
<CodeLine
|
||||
key={key}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 {
|
||||
@@ -75,6 +76,7 @@ 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>
|
||||
<View dataSet={CODE_SURFACE_DATASET}>
|
||||
{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>
|
||||
<View dataSet={CODE_SURFACE_DATASET}>
|
||||
{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: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.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: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: CODE_LINE_HEIGHT,
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import {
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextProps,
|
||||
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).
|
||||
export function MarkdownTextSpan({ style, children }: MarkdownTextSpanProps) {
|
||||
// (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) {
|
||||
return (
|
||||
<Text selectable style={style}>
|
||||
<Text selectable style={style} onPress={onPress} accessibilityRole={accessibilityRole}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
|
||||
import type { StyleProp, TextProps, 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 }: MarkdownTextSpanProps) {
|
||||
export function MarkdownTextSpan({
|
||||
style,
|
||||
children,
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
}: MarkdownTextSpanProps) {
|
||||
return (
|
||||
<UITextView uiTextView selectable style={style}>
|
||||
<UITextView
|
||||
uiTextView
|
||||
selectable
|
||||
style={style}
|
||||
onPress={onPress}
|
||||
accessibilityRole={accessibilityRole}
|
||||
>
|
||||
{children}
|
||||
</UITextView>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import {
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextProps,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
|
||||
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`
|
||||
@@ -11,8 +25,23 @@ 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, children }: MarkdownTextSpanProps) {
|
||||
return <Text style={style}>{children}</Text>;
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownParagraphViewProps {
|
||||
|
||||
@@ -11,6 +11,7 @@ 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,
|
||||
@@ -62,7 +63,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 { Fonts } from "@/constants/theme";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
@@ -1496,16 +1497,18 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
|
||||
onLinkPress,
|
||||
}: MemoizedMarkdownBlockProps) {
|
||||
return (
|
||||
<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>
|
||||
</AppearanceStyleBoundary>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1513,6 +1516,7 @@ interface MarkdownInheritedTextProps {
|
||||
inheritedStyles: TextStyle;
|
||||
textStyle: TextStyle;
|
||||
style?: StyleProp<TextStyle>;
|
||||
monoSurface?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -1520,13 +1524,18 @@ function MarkdownInheritedText({
|
||||
inheritedStyles,
|
||||
textStyle,
|
||||
style: overrideStyle,
|
||||
monoSurface,
|
||||
children,
|
||||
}: MarkdownInheritedTextProps) {
|
||||
const style = useMemo(
|
||||
() => [inheritedStyles, textStyle, overrideStyle],
|
||||
[inheritedStyles, textStyle, overrideStyle],
|
||||
);
|
||||
return <MarkdownTextSpan style={style}>{children}</MarkdownTextSpan>;
|
||||
return (
|
||||
<MarkdownTextSpan monoSurface={monoSurface} style={style}>
|
||||
{children}
|
||||
</MarkdownTextSpan>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownListItemContentProps {
|
||||
@@ -1613,6 +1622,67 @@ 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[],
|
||||
@@ -1696,6 +1766,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_inline}
|
||||
monoSurface
|
||||
>
|
||||
{content}
|
||||
</MarkdownInheritedText>
|
||||
@@ -1860,13 +1931,13 @@ const speakMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
headerLabel: {
|
||||
fontFamily: Fonts.sans,
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
text: {
|
||||
fontFamily: Fonts.sans,
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 22,
|
||||
color: theme.colors.foreground,
|
||||
@@ -1976,7 +2047,7 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({
|
||||
metadataText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.code,
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
lineHeight: 16,
|
||||
},
|
||||
}));
|
||||
@@ -2072,7 +2143,7 @@ export const ActivityLog = memo(function ActivityLog({
|
||||
</View>
|
||||
</View>
|
||||
{isExpanded && metadata && (
|
||||
<View style={activityLogStylesheet.metadataContainer}>
|
||||
<View style={activityLogStylesheet.metadataContainer} dataSet={CODE_SURFACE_DATASET}>
|
||||
<Text style={activityLogStylesheet.metadataText}>
|
||||
{JSON.stringify(metadata, null, 2)}
|
||||
</Text>
|
||||
@@ -2108,7 +2179,7 @@ const compactionStylesheet = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
text: {
|
||||
fontFamily: Fonts.sans,
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
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 { Fonts } from "@/constants/theme";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
@@ -52,7 +52,12 @@ function DiscoveredModelRow({ model }: { model: AgentModelDefinition }) {
|
||||
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
|
||||
{model.label}
|
||||
</Text>
|
||||
<Text style={sheetStyles.monoHint} numberOfLines={1} selectable>
|
||||
<Text
|
||||
style={sheetStyles.monoHint}
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
>
|
||||
{model.id}
|
||||
</Text>
|
||||
{model.description ? (
|
||||
@@ -89,7 +94,12 @@ function CustomModelRow({
|
||||
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
|
||||
{model.label}
|
||||
</Text>
|
||||
<Text style={sheetStyles.monoHint} numberOfLines={1} selectable>
|
||||
<Text
|
||||
style={sheetStyles.monoHint}
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
dataSet={CODE_SURFACE_DATASET}
|
||||
>
|
||||
{model.id}
|
||||
</Text>
|
||||
<View style={sheetStyles.modelRowFiller} />
|
||||
@@ -307,7 +317,7 @@ function DiagnosticSubSheet({
|
||||
body = (
|
||||
<ScrollView style={sheetStyles.codeScroll} contentContainerStyle={sheetStyles.codeContent}>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator>
|
||||
<Text style={sheetStyles.codeText} selectable>
|
||||
<Text style={sheetStyles.codeText} selectable dataSet={CODE_SURFACE_DATASET}>
|
||||
{diagnostic}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
@@ -607,7 +617,7 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
monoHint: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 0,
|
||||
@@ -716,7 +726,7 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
codeText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
|
||||
67
packages/app/src/components/resize-handle-sizes.test.ts
Normal file
67
packages/app/src/components/resize-handle-sizes.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
36
packages/app/src/components/resize-handle-sizes.ts
Normal file
36
packages/app/src/components/resize-handle-sizes.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { MIN_SPLIT_SIZE } from "@/stores/workspace-layout-constants";
|
||||
|
||||
interface ComputeResizeHandleSizesInput {
|
||||
sizes: number[];
|
||||
index: number;
|
||||
deltaRatio: number;
|
||||
minSize?: number;
|
||||
}
|
||||
|
||||
export function computeResizeHandleSizes({
|
||||
sizes,
|
||||
index,
|
||||
deltaRatio,
|
||||
minSize = MIN_SPLIT_SIZE,
|
||||
}: ComputeResizeHandleSizesInput): number[] {
|
||||
const nextSizes = sizes.slice();
|
||||
const leftSize = sizes[index];
|
||||
const rightSize = sizes[index + 1];
|
||||
if (leftSize === undefined || rightSize === undefined) {
|
||||
return nextSizes;
|
||||
}
|
||||
|
||||
const pairSize = leftSize + rightSize;
|
||||
if (pairSize <= 0) {
|
||||
return nextSizes;
|
||||
}
|
||||
|
||||
const adjacentMinSize = Math.min(minSize, pairSize / 2);
|
||||
const nextLeftSize = Math.min(
|
||||
pairSize - adjacentMinSize,
|
||||
Math.max(adjacentMinSize, leftSize + deltaRatio),
|
||||
);
|
||||
nextSizes[index] = nextLeftSize;
|
||||
nextSizes[index + 1] = pairSize - nextLeftSize;
|
||||
return nextSizes;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -13,8 +14,14 @@ export interface ResizeHandleProps {
|
||||
interface PointerState {
|
||||
containerSize: number;
|
||||
pointerStart: number;
|
||||
leftSize: number;
|
||||
rightSize: number;
|
||||
}
|
||||
|
||||
function resetWindowHorizontalScroll() {
|
||||
// Clamp any browser scroll introduced while dragging past the viewport edge.
|
||||
if (window.scrollX === 0) {
|
||||
return;
|
||||
}
|
||||
window.scrollTo(0, window.scrollY);
|
||||
}
|
||||
|
||||
export function ResizeHandle({
|
||||
@@ -25,7 +32,8 @@ export function ResizeHandle({
|
||||
onResizeSplit,
|
||||
}: ResizeHandleProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const pointerStateRef = useRef<PointerState | null>(null);
|
||||
const pointerStatesRef = useRef(new Map<number, PointerState>());
|
||||
const cursorBeforeDragRef = useRef<string | null>(null);
|
||||
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [active, setActive] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
@@ -34,7 +42,11 @@ export function ResizeHandle({
|
||||
const handlePointerDown = useCallback(
|
||||
(event: RNPointerEvent) => {
|
||||
const hitAreaElement = event.currentTarget as unknown as HTMLElement | null;
|
||||
const containerElement = hitAreaElement?.parentElement?.parentElement ?? null;
|
||||
if (!hitAreaElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerElement = hitAreaElement.parentElement?.parentElement ?? null;
|
||||
if (!containerElement) {
|
||||
return;
|
||||
}
|
||||
@@ -45,51 +57,83 @@ export function ResizeHandle({
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerId = event.nativeEvent.pointerId;
|
||||
if (pointerStatesRef.current.has(pointerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDragging(true);
|
||||
|
||||
pointerStateRef.current = {
|
||||
pointerStatesRef.current.set(pointerId, {
|
||||
containerSize,
|
||||
pointerStart:
|
||||
direction === "horizontal" ? event.nativeEvent.clientX : event.nativeEvent.clientY,
|
||||
leftSize: sizes[index] ?? 0,
|
||||
rightSize: sizes[index + 1] ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
const previousCursor = document.body.style.cursor;
|
||||
if (pointerStatesRef.current.size === 1) {
|
||||
cursorBeforeDragRef.current = 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() {
|
||||
pointerStateRef.current = null;
|
||||
setDragging(false);
|
||||
document.body.style.cursor = previousCursor;
|
||||
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();
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
window.removeEventListener("pointercancel", handlePointerUp);
|
||||
}
|
||||
|
||||
function handlePointerMove(moveEvent: PointerEvent) {
|
||||
const pointerState = pointerStateRef.current;
|
||||
if (moveEvent.pointerId !== pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerState = pointerStatesRef.current.get(pointerId);
|
||||
if (!pointerState) {
|
||||
return;
|
||||
}
|
||||
|
||||
moveEvent.preventDefault();
|
||||
resetWindowHorizontalScroll();
|
||||
const pointerCurrent = direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
|
||||
const deltaRatio =
|
||||
(pointerCurrent - pointerState.pointerStart) / pointerState.containerSize;
|
||||
|
||||
const nextSizes = sizes.slice();
|
||||
nextSizes[index] = pointerState.leftSize + deltaRatio;
|
||||
nextSizes[index + 1] = pointerState.rightSize - deltaRatio;
|
||||
onResizeSplit(groupId, nextSizes);
|
||||
onResizeSplit(
|
||||
groupId,
|
||||
computeResizeHandleSizes({
|
||||
sizes,
|
||||
index,
|
||||
deltaRatio,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
function handlePointerUp(upEvent: PointerEvent) {
|
||||
if (upEvent.pointerId !== pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
}
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp, { once: true });
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
window.addEventListener("pointercancel", handlePointerUp);
|
||||
},
|
||||
[direction, groupId, index, onResizeSplit, sizes],
|
||||
);
|
||||
@@ -130,6 +174,7 @@ 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: "canonical",
|
||||
projection: "projected",
|
||||
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ProjectIconView } from "@/components/project-icon-view";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
@@ -27,7 +28,11 @@ import {
|
||||
type Ref,
|
||||
} from "react";
|
||||
import { router, usePathname, type Href } from "expo-router";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import {
|
||||
navigateToWorkspace,
|
||||
useActiveWorkspaceSelection,
|
||||
type ActiveWorkspaceSelection,
|
||||
} 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";
|
||||
@@ -101,7 +106,6 @@ 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";
|
||||
@@ -173,6 +177,52 @@ 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;
|
||||
@@ -1493,7 +1543,6 @@ function WorkspaceRowWithMenu({
|
||||
isCreating?: boolean;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
|
||||
const queryClient = useQueryClient();
|
||||
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
|
||||
@@ -1516,9 +1565,9 @@ function WorkspaceRowWithMenu({
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
activeWorkspaceSelection,
|
||||
activeWorkspaceSelection: selectionForSelectedWorkspace(selected, workspace),
|
||||
});
|
||||
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
|
||||
}, [selected, workspace]);
|
||||
|
||||
const archiveWorktreeAfterConfirmation = useCallback(async () => {
|
||||
if (isArchiving) {
|
||||
@@ -1755,15 +1804,14 @@ 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,
|
||||
activeWorkspaceSelection: selectionForSelectedWorkspace(selected, workspace),
|
||||
});
|
||||
}, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]);
|
||||
}, [selected, workspace]);
|
||||
|
||||
const handleArchiveWorkspace = useCallback(() => {
|
||||
if (isArchivingWorkspace) {
|
||||
@@ -1883,6 +1931,7 @@ function FlattenedProjectRow({
|
||||
onRemoveProject,
|
||||
removeProjectStatus,
|
||||
selectionEnabled,
|
||||
activeWorkspaceSelection,
|
||||
}: {
|
||||
project: SidebarProjectEntry;
|
||||
displayName: string;
|
||||
@@ -1901,13 +1950,15 @@ function FlattenedProjectRow({
|
||||
onRemoveProject?: () => void;
|
||||
removeProjectStatus?: "idle" | "pending";
|
||||
selectionEnabled: boolean;
|
||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||
}) {
|
||||
const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId);
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const selected =
|
||||
selectionEnabled &&
|
||||
activeWorkspaceSelection?.serverId === serverId &&
|
||||
activeWorkspaceSelection.workspaceId === rowModel.workspace.workspaceId;
|
||||
const selected = isWorkspaceSelected({
|
||||
selection: activeWorkspaceSelection,
|
||||
serverId,
|
||||
workspaceId: rowModel.workspace.workspaceId,
|
||||
enabled: selectionEnabled,
|
||||
});
|
||||
|
||||
if (!workspace) {
|
||||
return null;
|
||||
@@ -1965,7 +2016,7 @@ interface WorkspaceRowItemProps {
|
||||
isCreating?: boolean;
|
||||
selectionEnabled: boolean;
|
||||
serverId: string | null;
|
||||
currentPathname: string | null;
|
||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||
onWorkspacePress?: () => void;
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
@@ -1980,7 +2031,7 @@ function WorkspaceRowItem({
|
||||
isCreating = false,
|
||||
selectionEnabled,
|
||||
serverId,
|
||||
currentPathname,
|
||||
activeWorkspaceSelection,
|
||||
onWorkspacePress,
|
||||
drag,
|
||||
isDragging = false,
|
||||
@@ -1991,8 +2042,8 @@ function WorkspaceRowItem({
|
||||
return;
|
||||
}
|
||||
onWorkspacePress?.();
|
||||
navigateToWorkspace(serverId, workspace.workspaceId, { currentPathname });
|
||||
}, [serverId, onWorkspacePress, workspace.workspaceId, currentPathname]);
|
||||
navigateToWorkspace(serverId, workspace.workspaceId);
|
||||
}, [serverId, onWorkspacePress, workspace.workspaceId]);
|
||||
|
||||
return (
|
||||
<WorkspaceRow
|
||||
@@ -2001,7 +2052,12 @@ function WorkspaceRowItem({
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
canCopyBranchName={canCopyBranchName}
|
||||
isCreating={isCreating}
|
||||
selectionEnabled={selectionEnabled}
|
||||
selected={isWorkspaceSelected({
|
||||
selection: activeWorkspaceSelection,
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
enabled: selectionEnabled,
|
||||
})}
|
||||
onPress={handlePress}
|
||||
drag={drag ?? noop}
|
||||
isDragging={isDragging}
|
||||
@@ -2010,6 +2066,39 @@ 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,
|
||||
@@ -2020,7 +2109,7 @@ function WorkspaceRow({
|
||||
dragHandleProps,
|
||||
canCopyBranchName,
|
||||
isCreating = false,
|
||||
selectionEnabled,
|
||||
selected,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
shortcutNumber: number | null;
|
||||
@@ -2031,14 +2120,9 @@ function WorkspaceRow({
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
canCopyBranchName: boolean;
|
||||
isCreating?: boolean;
|
||||
selectionEnabled: boolean;
|
||||
selected: 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;
|
||||
@@ -2074,12 +2158,12 @@ function ProjectBlock({
|
||||
onWorkspacePress,
|
||||
onWorkspaceReorder,
|
||||
onWorktreeCreated,
|
||||
currentPathname,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
useNestable,
|
||||
creatingWorkspaceIds,
|
||||
activeWorkspaceSelection,
|
||||
}: {
|
||||
project: SidebarProjectEntry;
|
||||
collapsed: boolean;
|
||||
@@ -2094,12 +2178,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(
|
||||
() =>
|
||||
@@ -2110,15 +2194,12 @@ function ProjectBlock({
|
||||
[collapsed, project],
|
||||
);
|
||||
|
||||
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 active = isProjectSelectedByRoute({
|
||||
selection: activeWorkspaceSelection,
|
||||
serverId,
|
||||
project,
|
||||
enabled: selectionEnabled,
|
||||
});
|
||||
|
||||
const renderWorkspaceRow = useCallback(
|
||||
(
|
||||
@@ -2130,7 +2211,7 @@ function ProjectBlock({
|
||||
},
|
||||
) => {
|
||||
return (
|
||||
<WorkspaceRowItem
|
||||
<MemoWorkspaceRowItem
|
||||
workspace={item}
|
||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
@@ -2138,7 +2219,7 @@ function ProjectBlock({
|
||||
isCreating={creatingWorkspaceIds.has(item.workspaceId)}
|
||||
selectionEnabled={selectionEnabled}
|
||||
serverId={serverId}
|
||||
currentPathname={currentPathname}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
drag={input?.drag}
|
||||
isDragging={input?.isDragging}
|
||||
@@ -2148,8 +2229,8 @@ function ProjectBlock({
|
||||
},
|
||||
[
|
||||
project.projectKind,
|
||||
activeWorkspaceSelection,
|
||||
creatingWorkspaceIds,
|
||||
currentPathname,
|
||||
onWorkspacePress,
|
||||
serverId,
|
||||
selectionEnabled,
|
||||
@@ -2228,10 +2309,8 @@ function ProjectBlock({
|
||||
return;
|
||||
}
|
||||
onWorkspacePress?.();
|
||||
navigateToWorkspace(serverId, flattenedRowWorkspaceId, {
|
||||
currentPathname,
|
||||
});
|
||||
}, [serverId, flattenedRowWorkspaceId, onWorkspacePress, currentPathname]);
|
||||
navigateToWorkspace(serverId, flattenedRowWorkspaceId);
|
||||
}, [serverId, flattenedRowWorkspaceId, onWorkspacePress]);
|
||||
|
||||
const handleToggleCollapsed = useCallback(() => {
|
||||
onToggleCollapsed(project.projectKey);
|
||||
@@ -2254,10 +2333,11 @@ function ProjectBlock({
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
isProjectActive={isProjectActive}
|
||||
isProjectActive={active}
|
||||
onRemoveProject={handleRemoveProject}
|
||||
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
||||
selectionEnabled={selectionEnabled}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -2271,7 +2351,7 @@ function ProjectBlock({
|
||||
onPress={handleToggleCollapsed}
|
||||
serverId={serverId}
|
||||
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
|
||||
isProjectActive={isProjectActive}
|
||||
isProjectActive={active}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onWorktreeCreated={onWorktreeCreated}
|
||||
drag={drag}
|
||||
@@ -2303,6 +2383,46 @@ 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,
|
||||
@@ -2333,6 +2453,7 @@ export function SidebarWorkspaceList({
|
||||
[pathname],
|
||||
);
|
||||
const selectionEnabled = isWorkspaceRoute;
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
|
||||
const projectIconRequests = useMemo(() => {
|
||||
if (!serverId) {
|
||||
@@ -2374,6 +2495,12 @@ 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) {
|
||||
@@ -2381,10 +2508,7 @@ export function SidebarWorkspaceList({
|
||||
if (!request) {
|
||||
continue;
|
||||
}
|
||||
iconByServerAndCwd.set(
|
||||
`${request.serverId}:${request.cwd}`,
|
||||
projectIconQueries[index]?.data ?? null,
|
||||
);
|
||||
iconByServerAndCwd.set(`${request.serverId}:${request.cwd}`, projectIconData[index] ?? null);
|
||||
}
|
||||
|
||||
const byProject = new Map<string, string | null>();
|
||||
@@ -2398,7 +2522,7 @@ export function SidebarWorkspaceList({
|
||||
}
|
||||
|
||||
return byProject;
|
||||
}, [projectIconQueries, projectIconRequests, projects, serverId]);
|
||||
}, [projectIconData, projectIconRequests, projects, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeouts = creatingWorkspaceTimeoutsRef.current;
|
||||
@@ -2532,7 +2656,7 @@ export function SidebarWorkspaceList({
|
||||
const renderProject = useCallback(
|
||||
({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) => {
|
||||
return (
|
||||
<ProjectBlock
|
||||
<MemoProjectBlock
|
||||
project={item}
|
||||
collapsed={collapsedProjectKeys.has(item.projectKey)}
|
||||
displayName={item.projectName}
|
||||
@@ -2546,23 +2670,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,
|
||||
|
||||
@@ -67,6 +67,7 @@ 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";
|
||||
|
||||
@@ -78,7 +79,6 @@ 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,14 +215,16 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
||||
}, [onFocusPane, paneId]);
|
||||
|
||||
return (
|
||||
<View style={wrapperStyle}>
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onFocusPane={handleFocusPane}
|
||||
/>
|
||||
</View>
|
||||
<RenderProfile id={`DesktopMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
|
||||
<View style={wrapperStyle}>
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onFocusPane={handleFocusPane}
|
||||
/>
|
||||
</View>
|
||||
</RenderProfile>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -355,7 +357,6 @@ export function SplitContainer({
|
||||
isWorkspaceFocused,
|
||||
uiTabs,
|
||||
hoveredCloseTabKey,
|
||||
setHoveredTabKey,
|
||||
setHoveredCloseTabKey,
|
||||
closingTabIds,
|
||||
onNavigateTab,
|
||||
@@ -553,63 +554,64 @@ export function SplitContainer({
|
||||
);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -711,7 +713,6 @@ function SplitNodeView({
|
||||
normalizedWorkspaceId,
|
||||
isWorkspaceFocused,
|
||||
hoveredCloseTabKey,
|
||||
setHoveredTabKey,
|
||||
setHoveredCloseTabKey,
|
||||
closingTabIds,
|
||||
onNavigateTab,
|
||||
@@ -764,7 +765,6 @@ function SplitNodeView({
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
closingTabIds={closingTabIds}
|
||||
onNavigateTab={onNavigateTab}
|
||||
@@ -810,7 +810,6 @@ function SplitNodeView({
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
closingTabIds={closingTabIds}
|
||||
onNavigateTab={onNavigateTab}
|
||||
@@ -862,7 +861,6 @@ function SplitPaneView({
|
||||
normalizedWorkspaceId,
|
||||
isWorkspaceFocused,
|
||||
hoveredCloseTabKey,
|
||||
setHoveredTabKey,
|
||||
setHoveredCloseTabKey,
|
||||
closingTabIds,
|
||||
onNavigateTab,
|
||||
@@ -995,66 +993,67 @@ function SplitPaneView({
|
||||
);
|
||||
|
||||
return (
|
||||
<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 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;
|
||||
}
|
||||
<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} />
|
||||
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>
|
||||
</View>
|
||||
</RenderProfile>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,12 +45,15 @@ 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;
|
||||
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
|
||||
onFocus?: () => Promise<void> | void;
|
||||
onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise<void> | void;
|
||||
onTerminalKey?: (input: {
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
@@ -80,6 +83,8 @@ type BridgeInboundMessage =
|
||||
initialSnapshot: TerminalState | null;
|
||||
scrollbackLines: number;
|
||||
theme: ITheme;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
pendingModifiers: PendingTerminalModifiers;
|
||||
swipeGesturesEnabled: boolean;
|
||||
}
|
||||
@@ -89,9 +94,10 @@ type BridgeInboundMessage =
|
||||
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
|
||||
| { type: "clear"; streamKey: string }
|
||||
| { type: "focus"; streamKey: string; forceRefocus?: boolean }
|
||||
| { type: "resize"; streamKey: string }
|
||||
| { type: "resize"; streamKey: string; shouldClaim?: boolean }
|
||||
| { 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 }
|
||||
| {
|
||||
@@ -105,7 +111,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 }
|
||||
| { type: "resize"; streamKey: string; rows: number; cols: number; shouldClaim?: boolean }
|
||||
| {
|
||||
type: "terminalKey";
|
||||
streamKey: string;
|
||||
@@ -159,6 +165,8 @@ function createMountMessage(input: {
|
||||
initialSnapshot: TerminalState | null;
|
||||
scrollbackLines: number;
|
||||
theme: ITheme;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
pendingModifiers: PendingTerminalModifiers;
|
||||
swipeGesturesEnabled: boolean;
|
||||
}): BridgeInboundMessage {
|
||||
@@ -168,6 +176,8 @@ function createMountMessage(input: {
|
||||
initialSnapshot: input.initialSnapshot,
|
||||
scrollbackLines: input.scrollbackLines,
|
||||
theme: input.theme,
|
||||
fontFamily: input.fontFamily,
|
||||
fontSize: input.fontSize,
|
||||
pendingModifiers: input.pendingModifiers,
|
||||
swipeGesturesEnabled: input.swipeGesturesEnabled,
|
||||
};
|
||||
@@ -183,11 +193,14 @@ export default function TerminalEmulator({
|
||||
cursor: "#e6e6e6",
|
||||
},
|
||||
scrollbackLines,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
swipeGesturesEnabled = false,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
initialSnapshot = null,
|
||||
onInput,
|
||||
onFocus,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
@@ -216,6 +229,8 @@ export default function TerminalEmulator({
|
||||
initialSnapshot,
|
||||
scrollbackLines,
|
||||
theme: xtermTheme,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
pendingModifiers,
|
||||
swipeGesturesEnabled,
|
||||
});
|
||||
@@ -224,11 +239,14 @@ export default function TerminalEmulator({
|
||||
initialSnapshot,
|
||||
scrollbackLines,
|
||||
theme: xtermTheme,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
pendingModifiers,
|
||||
swipeGesturesEnabled,
|
||||
};
|
||||
const callbacksRef = useRef({
|
||||
onInput,
|
||||
onFocus,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
@@ -241,6 +259,7 @@ export default function TerminalEmulator({
|
||||
});
|
||||
callbacksRef.current = {
|
||||
onInput,
|
||||
onFocus,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
@@ -389,6 +408,11 @@ 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 });
|
||||
@@ -401,14 +425,14 @@ export default function TerminalEmulator({
|
||||
|
||||
useEffect(() => {
|
||||
if (focusRequestToken <= 0) return;
|
||||
sendToWebView({ type: "resize", streamKey });
|
||||
sendToWebView({ type: "resize", streamKey, shouldClaim: true });
|
||||
sendToWebView({ type: "focus", streamKey });
|
||||
webViewRef.current?.requestFocus();
|
||||
}, [focusRequestToken, sendToWebView, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resizeRequestToken <= 0) return;
|
||||
sendToWebView({ type: "resize", streamKey });
|
||||
sendToWebView({ type: "resize", streamKey, shouldClaim: true });
|
||||
}, [resizeRequestToken, sendToWebView, streamKey]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -495,7 +519,11 @@ export default function TerminalEmulator({
|
||||
callbacksRef.current.onInput?.(message.data);
|
||||
break;
|
||||
case "resize":
|
||||
callbacksRef.current.onResize?.({ rows: message.rows, cols: message.cols });
|
||||
callbacksRef.current.onResize?.({
|
||||
rows: message.rows,
|
||||
cols: message.cols,
|
||||
shouldClaim: message.shouldClaim !== false,
|
||||
});
|
||||
break;
|
||||
case "terminalKey":
|
||||
callbacksRef.current.onTerminalKey?.({
|
||||
@@ -592,6 +620,7 @@ export default function TerminalEmulator({
|
||||
return;
|
||||
}
|
||||
webViewRef.current?.requestFocus();
|
||||
callbacksRef.current.onFocus?.();
|
||||
sendToWebView({ type: "focus", streamKey, forceRefocus: true });
|
||||
}, [sendToWebView, streamKey]);
|
||||
|
||||
|
||||
@@ -134,12 +134,15 @@ 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;
|
||||
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
|
||||
onFocus?: () => Promise<void> | void;
|
||||
onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise<void> | void;
|
||||
onTerminalKey?: (input: {
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
@@ -214,11 +217,14 @@ export default function TerminalEmulator({
|
||||
cursor: "#e6e6e6",
|
||||
},
|
||||
scrollbackLines,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
swipeGesturesEnabled = false,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
initialSnapshot = null,
|
||||
onInput,
|
||||
onFocus,
|
||||
onResize,
|
||||
onTerminalKey,
|
||||
onPendingModifiersConsumed,
|
||||
@@ -234,8 +240,12 @@ 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);
|
||||
@@ -484,6 +494,8 @@ export default function TerminalEmulator({
|
||||
initialSnapshot: initialSnapshotRef.current,
|
||||
scrollback: scrollbackLinesRef.current,
|
||||
theme: mountedThemeRef.current,
|
||||
fontFamily: fontFamilyRef.current,
|
||||
fontSize: fontSizeRef.current,
|
||||
});
|
||||
onRendererReadyChangeRef.current?.({ streamKey, isReady: true });
|
||||
|
||||
@@ -523,11 +535,15 @@ 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 });
|
||||
runtimeRef.current?.resize({ force: true, shouldClaim: true });
|
||||
return focusWithRetries({
|
||||
focus: () => {
|
||||
runtimeRef.current?.focus();
|
||||
@@ -547,7 +563,7 @@ export default function TerminalEmulator({
|
||||
if (resizeRequestToken <= 0) {
|
||||
return;
|
||||
}
|
||||
runtimeRef.current?.resize({ force: true });
|
||||
runtimeRef.current?.resize({ force: true, shouldClaim: true });
|
||||
}, [resizeRequestToken]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -742,8 +758,9 @@ export default function TerminalEmulator({
|
||||
}, []);
|
||||
|
||||
const handleRootPointerDown = useCallback(() => {
|
||||
onFocus?.();
|
||||
runtimeRef.current?.focus();
|
||||
}, []);
|
||||
}, [onFocus]);
|
||||
|
||||
const handleRootContextMenu = useCallback(
|
||||
(event: ReactMouseEvent) => {
|
||||
|
||||
@@ -173,6 +173,10 @@ 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);
|
||||
@@ -214,6 +218,7 @@ 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(() => {
|
||||
@@ -252,7 +257,7 @@ export function TerminalPane({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) {
|
||||
if (isMobile || !isPaneFocused || !terminalId) {
|
||||
lastAutoFocusKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
@@ -263,15 +268,36 @@ export function TerminalPane({
|
||||
}
|
||||
|
||||
lastAutoFocusKeyRef.current = nextFocusKey;
|
||||
if (!isWorkspaceFocused) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestTerminalFocus();
|
||||
}, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPaneFocused && isWorkspaceFocused && isAppVisible && terminalId) {
|
||||
lastSentTerminalSizeRef.current = null;
|
||||
requestTerminalReflow();
|
||||
if (!isPaneFocused || !terminalId) {
|
||||
lastPaneFocusResizeKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
}, [isAppVisible, isPaneFocused, isWorkspaceFocused, requestTerminalReflow, terminalId]);
|
||||
|
||||
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]);
|
||||
|
||||
const clearKeyboardRefitTimeouts = useCallback(() => {
|
||||
if (keyboardRefitTimeoutsRef.current.length === 0) {
|
||||
@@ -308,25 +334,6 @@ 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;
|
||||
@@ -375,7 +382,7 @@ export function TerminalPane({
|
||||
|
||||
const controller = new TerminalStreamController({
|
||||
client,
|
||||
getPreferredSize: () => measuredTerminalSizeRef.current,
|
||||
getPreferredSize: () => lastSentTerminalSizeRef.current,
|
||||
onOutput: ({ terminalId: outputTerminalId, data }) => {
|
||||
if (!isWorkspaceFocused || terminalIdRef.current !== outputTerminalId) {
|
||||
return;
|
||||
@@ -605,33 +612,35 @@ export function TerminalPane({
|
||||
],
|
||||
);
|
||||
|
||||
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 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 handleTerminalKey = useCallback(
|
||||
async (input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }) => {
|
||||
@@ -758,12 +767,15 @@ 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,13 +8,14 @@ import {
|
||||
} from "react-native";
|
||||
import { ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
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";
|
||||
@@ -179,7 +180,7 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
|
||||
style={ds.webScrollbarStyle}
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine}>
|
||||
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
<Text style={styles.shellPrompt}>$ </Text>
|
||||
{normalizedCommand}
|
||||
@@ -224,7 +225,7 @@ function WorktreeSetupDetailSection({
|
||||
style={ds.webScrollbarStyle}
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine}>
|
||||
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{hasLog ? setupLog : `Preparing worktree ${branchName} at ${worktreePath}`}
|
||||
</Text>
|
||||
@@ -383,7 +384,7 @@ function SubAgentDetailSection({
|
||||
style={ds.webScrollbarStyle}
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine}>
|
||||
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
|
||||
{childSessionId ? (
|
||||
<Text selectable style={styles.subAgentSessionText}>
|
||||
session {childSessionId}
|
||||
@@ -466,7 +467,7 @@ function ScrollableTextSection({
|
||||
{keyedLines ? (
|
||||
<HighlightedLines lines={keyedLines} startLine={startLine} />
|
||||
) : (
|
||||
<Text selectable style={styles.scrollText}>
|
||||
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
|
||||
{content}
|
||||
</Text>
|
||||
)}
|
||||
@@ -498,7 +499,7 @@ function FetchDetailSection({ url, result, ds }: FetchDetailProps) {
|
||||
showsHorizontalScrollIndicator
|
||||
style={ds.webScrollbarStyle}
|
||||
>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
|
||||
{result ? `${url}\n\n${result}` : url}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
@@ -542,7 +543,7 @@ function buildSearchSections(detail: SearchDetail, ds: DetailStyles): ReactNode[
|
||||
showsHorizontalScrollIndicator
|
||||
style={ds.webScrollbarStyle}
|
||||
>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
|
||||
{detail.content}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
@@ -553,7 +554,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}>
|
||||
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
|
||||
{detail.filePaths.join("\n")}
|
||||
</Text>
|
||||
</View>,
|
||||
@@ -562,7 +563,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}>
|
||||
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
|
||||
{detail.webResults.map((entry) => `${entry.title}\n${entry.url}`).join("\n\n")}
|
||||
</Text>
|
||||
</View>,
|
||||
@@ -571,7 +572,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}>
|
||||
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
|
||||
{detail.annotations.join("\n\n")}
|
||||
</Text>
|
||||
</View>,
|
||||
@@ -638,7 +639,7 @@ function buildUnknownSections(detail: UnknownDetail, ds: DetailStyles): ReactNod
|
||||
contentContainerStyle={styles.jsonContent}
|
||||
showsHorizontalScrollIndicator={true}
|
||||
>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
<Text selectable style={styles.scrollText} dataSet={CODE_SURFACE_DATASET}>
|
||||
{value}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
@@ -738,7 +739,7 @@ function ErrorSection({ errorText, ds }: { errorText: string; ds: DetailStyles }
|
||||
contentContainerStyle={styles.jsonContent}
|
||||
showsHorizontalScrollIndicator={true}
|
||||
>
|
||||
<Text selectable style={SCROLL_TEXT_ERROR_STYLE}>
|
||||
<Text selectable style={SCROLL_TEXT_ERROR_STYLE} dataSet={CODE_SURFACE_DATASET}>
|
||||
{errorText}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
@@ -756,7 +757,15 @@ function LoadingSkeleton({ containerStyle }: { containerStyle: StyleProp<ViewSty
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolCallDetailsContent({
|
||||
export function ToolCallDetailsContent({ ...props }: ToolCallDetailsContentProps) {
|
||||
return (
|
||||
<AppearanceStyleBoundary>
|
||||
<ToolCallDetailsContentInner {...props} />
|
||||
</AppearanceStyleBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCallDetailsContentInner({
|
||||
detail,
|
||||
errorText,
|
||||
maxHeight,
|
||||
@@ -823,7 +832,7 @@ const styles = StyleSheet.create((theme) => {
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
plainText: {
|
||||
fontFamily: Fonts.sans,
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 22,
|
||||
@@ -876,7 +885,7 @@ const styles = StyleSheet.create((theme) => {
|
||||
padding: insets.padding,
|
||||
},
|
||||
scrollText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
@@ -891,7 +900,7 @@ const styles = StyleSheet.create((theme) => {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
subAgentSessionText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foregroundMuted,
|
||||
lineHeight: 18,
|
||||
@@ -907,13 +916,13 @@ const styles = StyleSheet.create((theme) => {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
subAgentActionTool: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foregroundMuted,
|
||||
lineHeight: 18,
|
||||
},
|
||||
subAgentActionSummary: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -30,6 +31,7 @@ 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";
|
||||
@@ -178,10 +180,19 @@ function renderDropdownSurface(input: {
|
||||
scrollable: boolean;
|
||||
scrollViewportStyle: StyleProp<ViewStyle>;
|
||||
content: ReactElement;
|
||||
surfaceNativeID: string;
|
||||
onExited: () => void;
|
||||
}): ReactElement {
|
||||
const { frameStyle, testID, surfaceStyle, scrollable, scrollViewportStyle, content, onExited } =
|
||||
input;
|
||||
const {
|
||||
frameStyle,
|
||||
testID,
|
||||
surfaceStyle,
|
||||
scrollable,
|
||||
scrollViewportStyle,
|
||||
content,
|
||||
surfaceNativeID,
|
||||
onExited,
|
||||
} = input;
|
||||
|
||||
const body = scrollable ? (
|
||||
<FloatingScrollView
|
||||
@@ -199,6 +210,7 @@ function renderDropdownSurface(input: {
|
||||
return (
|
||||
<FloatingSurface
|
||||
collapsable={false}
|
||||
nativeID={surfaceNativeID}
|
||||
testID={testID}
|
||||
style={surfaceStyle}
|
||||
frameStyle={frameStyle}
|
||||
@@ -350,16 +362,58 @@ 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(150);
|
||||
}).duration(CONTENT_ENTERING_DURATION_MS);
|
||||
|
||||
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",
|
||||
@@ -389,6 +443,7 @@ 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);
|
||||
@@ -429,6 +484,12 @@ export function DropdownMenuContent({
|
||||
}
|
||||
}, [flushPendingSelect, modalVisible, open]);
|
||||
|
||||
useReleaseFixedMenuHeight({
|
||||
contentSize,
|
||||
enabled: modalVisible,
|
||||
surfaceNativeID,
|
||||
});
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, [setOpen]);
|
||||
@@ -575,6 +636,7 @@ export function DropdownMenuContent({
|
||||
scrollable,
|
||||
scrollViewportStyle,
|
||||
content,
|
||||
surfaceNativeID,
|
||||
onExited: () => setModalVisible(false),
|
||||
})
|
||||
: null}
|
||||
|
||||
@@ -97,6 +97,7 @@ interface ControlledAgentControlsProps {
|
||||
/** Extra elements rendered inline with the agent controls (desktop only). */
|
||||
desktopExtras?: ReactNode;
|
||||
modelSelectorServerId?: string | null;
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
export interface DraftAgentControlsProps {
|
||||
@@ -124,12 +125,14 @@ export interface DraftAgentControlsProps {
|
||||
isRetryingModelProvider?: boolean;
|
||||
disabled?: boolean;
|
||||
modelSelectorServerId?: string | null;
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
interface AgentControlsProps {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
onDropdownClose?: () => void;
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
function findOptionLabel(
|
||||
@@ -409,9 +412,11 @@ function ControlledAgentControls({
|
||||
isRetryingModelProvider = false,
|
||||
desktopExtras,
|
||||
modelSelectorServerId = null,
|
||||
isCompactLayout,
|
||||
}: ControlledAgentControlsProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompact = isCompactLayout ?? isCompactFormFactor;
|
||||
const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null);
|
||||
const [openSelector, setOpenSelector] = useState<AgentControlSelector | null>(null);
|
||||
|
||||
@@ -1345,6 +1350,7 @@ export const AgentControls = memo(function AgentControls({
|
||||
agentId,
|
||||
serverId,
|
||||
onDropdownClose,
|
||||
isCompactLayout,
|
||||
}: AgentControlsProps) {
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
const agent = useSessionStore(
|
||||
@@ -1518,8 +1524,15 @@ export const AgentControls = memo(function AgentControls({
|
||||
);
|
||||
|
||||
const modeChip = useMemo(
|
||||
() => <AgentModeControl serverId={serverId} agentId={agentId} placement="toolbar" />,
|
||||
[serverId, agentId],
|
||||
() => (
|
||||
<AgentModeControl
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
placement="toolbar"
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
),
|
||||
[serverId, agentId, isCompactLayout],
|
||||
);
|
||||
|
||||
if (!agent) {
|
||||
@@ -1548,6 +1561,7 @@ export const AgentControls = memo(function AgentControls({
|
||||
disabled={!client}
|
||||
desktopExtras={modeChip}
|
||||
modelSelectorServerId={serverId}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1577,9 +1591,11 @@ export function DraftAgentControls({
|
||||
isRetryingModelProvider = false,
|
||||
disabled = false,
|
||||
modelSelectorServerId = null,
|
||||
isCompactLayout,
|
||||
}: DraftAgentControlsProps) {
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompact = isCompactLayout ?? isCompactFormFactor;
|
||||
|
||||
const mappedThinkingOptions = useMemo<AgentControlOption[]>(() => {
|
||||
return toThinkingControlOptions(thinkingOptions);
|
||||
@@ -1625,9 +1641,18 @@ export function DraftAgentControls({
|
||||
selectedMode={selectedMode}
|
||||
onSelectMode={onSelectMode}
|
||||
disabled={disabled}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
),
|
||||
[selectedProvider, providerDefinitions, modeOptions, selectedMode, onSelectMode, disabled],
|
||||
[
|
||||
selectedProvider,
|
||||
providerDefinitions,
|
||||
modeOptions,
|
||||
selectedMode,
|
||||
onSelectMode,
|
||||
disabled,
|
||||
isCompactLayout,
|
||||
],
|
||||
);
|
||||
|
||||
if (!isCompact) {
|
||||
@@ -1661,6 +1686,7 @@ export function DraftAgentControls({
|
||||
isRetryingModelProvider={isRetryingModelProvider}
|
||||
disabled={disabled}
|
||||
desktopExtras={draftModeChip}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -1688,6 +1714,7 @@ export function DraftAgentControls({
|
||||
isRetryingModelProvider={isRetryingModelProvider}
|
||||
disabled={disabled}
|
||||
modelSelectorServerId={modelSelectorServerId}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -235,14 +235,17 @@ interface AgentModeControlProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
placement: AgentModeControlPlacement;
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
export const AgentModeControl = memo(function AgentModeControl({
|
||||
serverId,
|
||||
agentId,
|
||||
placement,
|
||||
isCompactLayout,
|
||||
}: AgentModeControlProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompact = isCompactLayout ?? isCompactFormFactor;
|
||||
const slice = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const agent = state.sessions[serverId]?.agents?.get(agentId);
|
||||
@@ -303,6 +306,7 @@ export interface DraftAgentModeControlProps {
|
||||
onSelectMode: (modeId: string) => void;
|
||||
disabled?: boolean;
|
||||
placement: AgentModeControlPlacement;
|
||||
isCompactLayout?: boolean;
|
||||
}
|
||||
|
||||
export function DraftAgentModeControl({
|
||||
@@ -313,8 +317,10 @@ export function DraftAgentModeControl({
|
||||
onSelectMode,
|
||||
disabled,
|
||||
placement,
|
||||
isCompactLayout,
|
||||
}: DraftAgentModeControlProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompact = isCompactLayout ?? isCompactFormFactor;
|
||||
if (!selectedProvider || modeOptions.length === 0) return null;
|
||||
if (!shouldRenderForPlacement(placement, isCompact)) return null;
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -36,7 +37,11 @@ import {
|
||||
useWorkspaceAttachmentScopeKey,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
COMPACT_FORM_FACTOR_WIDTH,
|
||||
MAX_CONTENT_WIDTH,
|
||||
useIsCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
|
||||
|
||||
@@ -380,7 +385,11 @@ export function WorkspaceDraftAgentTab({
|
||||
};
|
||||
}, [pendingAutoSubmit, pendingCreateAttempt]);
|
||||
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
|
||||
COMPACT_FORM_FACTOR_WIDTH,
|
||||
{ initialIsBelow: isCompactFormFactor },
|
||||
);
|
||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||
serverId,
|
||||
cwd: composerState.workingDir,
|
||||
@@ -401,14 +410,14 @@ export function WorkspaceDraftAgentTab({
|
||||
};
|
||||
openFileExplorerForCheckout({
|
||||
checkout,
|
||||
isCompact,
|
||||
isCompact: isCompactFormFactor,
|
||||
});
|
||||
setExplorerTabForCheckout({
|
||||
...checkout,
|
||||
tab: "changes",
|
||||
});
|
||||
},
|
||||
[isCompact, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
|
||||
[isCompactFormFactor, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
|
||||
);
|
||||
|
||||
const {
|
||||
@@ -625,10 +634,14 @@ export function WorkspaceDraftAgentTab({
|
||||
);
|
||||
const composerFooter = useMemo(
|
||||
() =>
|
||||
isCompact ? (
|
||||
<DraftAgentModeControl placement="footer" {...composerAgentControls} />
|
||||
isCompactComposerLayout ? (
|
||||
<DraftAgentModeControl
|
||||
placement="footer"
|
||||
{...composerAgentControls}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
) : undefined,
|
||||
[isCompact, composerAgentControls],
|
||||
[isCompactComposerLayout, composerAgentControls],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -662,7 +675,7 @@ export function WorkspaceDraftAgentTab({
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ReanimatedAnimated.View style={inputAreaWrapperStyle}>
|
||||
<ReanimatedAnimated.View style={inputAreaWrapperStyle} onLayout={onInputAreaLayout}>
|
||||
{importPillPress ? (
|
||||
<View style={styles.importPillRow}>
|
||||
<View style={styles.importPillContent}>
|
||||
@@ -692,6 +705,7 @@ export function WorkspaceDraftAgentTab({
|
||||
commandDraftConfig={composerState.commandDraftConfig}
|
||||
agentControls={composerAgentControls}
|
||||
footer={composerFooter}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
|
||||
@@ -140,6 +140,10 @@ 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;
|
||||
}
|
||||
@@ -190,6 +194,7 @@ function renderContextWindowMeter(
|
||||
contextWindowMaxTokens: number | null,
|
||||
contextWindowUsedTokens: number | null,
|
||||
totalCostUsd: number | null,
|
||||
showPercentage: boolean,
|
||||
): ReactElement | null {
|
||||
if (contextWindowMaxTokens === null || contextWindowUsedTokens === null) {
|
||||
return null;
|
||||
@@ -199,6 +204,7 @@ function renderContextWindowMeter(
|
||||
maxTokens={contextWindowMaxTokens}
|
||||
usedTokens={contextWindowUsedTokens}
|
||||
totalCostUsd={totalCostUsd}
|
||||
showPercentage={showPercentage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -206,13 +212,13 @@ function renderContextWindowMeter(
|
||||
function resolveContextWindowPlacement(
|
||||
meter: ReactElement | null,
|
||||
isMobile: boolean,
|
||||
): { beforeVoiceContent: ReactNode; footerRight: ReactNode } {
|
||||
): { beforeVoiceContent: ReactNode; footerInlineContent: ReactNode } {
|
||||
if (isMobile) {
|
||||
return { beforeVoiceContent: null, footerRight: meter };
|
||||
return { beforeVoiceContent: null, footerInlineContent: meter };
|
||||
}
|
||||
return {
|
||||
beforeVoiceContent: <View style={styles.contextWindowMeterSlot}>{meter}</View>,
|
||||
footerRight: null,
|
||||
footerInlineContent: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -221,14 +227,22 @@ interface RenderLeftContentArgs {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
focusInput: () => void;
|
||||
isCompactLayout: boolean;
|
||||
}
|
||||
|
||||
function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
|
||||
const { agentControls, agentId, serverId, focusInput } = args;
|
||||
const { agentControls, agentId, serverId, focusInput, isCompactLayout } = args;
|
||||
if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) {
|
||||
return <DraftAgentControls {...agentControls} />;
|
||||
return <DraftAgentControls {...agentControls} isCompactLayout={isCompactLayout} />;
|
||||
}
|
||||
return <AgentControls agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />;
|
||||
return (
|
||||
<AgentControls
|
||||
agentId={agentId}
|
||||
serverId={serverId}
|
||||
onDropdownClose={focusInput}
|
||||
isCompactLayout={isCompactLayout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface RenderAttachmentTrayArgs {
|
||||
@@ -238,13 +252,18 @@ interface RenderAttachmentTrayArgs {
|
||||
handleRemoveAttachment: (index: number) => void;
|
||||
}
|
||||
|
||||
function renderComposerFooter(footer: ReactNode, footerRight: ReactNode): ReactElement | null {
|
||||
if (!footer && !footerRight) return null;
|
||||
function renderComposerFooter(
|
||||
footer: ReactNode,
|
||||
footerInlineContent: ReactNode,
|
||||
): ReactElement | null {
|
||||
if (!footer && !footerInlineContent) return null;
|
||||
return (
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.footerContent}>
|
||||
<View style={styles.footerLeft}>{footer}</View>
|
||||
<View style={styles.footerRight}>{footerRight}</View>
|
||||
<View style={styles.footerLeft}>
|
||||
{footer}
|
||||
{footerInlineContent}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -667,6 +686,8 @@ 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[] = [];
|
||||
@@ -862,6 +883,7 @@ export function Composer({
|
||||
inputWrapperStyle,
|
||||
footer,
|
||||
externalKeyboardShift,
|
||||
isCompactLayout: isCompactLayoutOverride,
|
||||
}: ComposerProps) {
|
||||
const buttonIconSize = resolveComposerButtonIconSize();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
@@ -892,9 +914,11 @@ export function Composer({
|
||||
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
||||
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
|
||||
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isMobile);
|
||||
const messagePlaceholder = resolveMessagePlaceholder(isDesktopWebBreakpoint);
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
|
||||
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
|
||||
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
|
||||
const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout);
|
||||
const userInput = value;
|
||||
const setUserInput = onChangeText;
|
||||
const {
|
||||
@@ -1430,7 +1454,7 @@ export function Composer({
|
||||
isAgentRunning={isAgentRunning}
|
||||
hasSendableContent={hasSendableContent}
|
||||
isProcessing={isProcessing}
|
||||
isCompact={isMobile}
|
||||
isCompact={isCompactLayout}
|
||||
buttonIconSize={buttonIconSize}
|
||||
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
|
||||
isConnected={isConnected}
|
||||
@@ -1448,7 +1472,7 @@ export function Composer({
|
||||
hasSendableContent,
|
||||
isAgentRunning,
|
||||
isConnected,
|
||||
isMobile,
|
||||
isCompactLayout,
|
||||
isProcessing,
|
||||
isVoiceModeForAgent,
|
||||
isVoiceSwitching,
|
||||
@@ -1468,12 +1492,13 @@ export function Composer({
|
||||
contextWindowMaxTokens,
|
||||
contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
isCompactLayout,
|
||||
),
|
||||
[contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd],
|
||||
[contextWindowMaxTokens, contextWindowUsedTokens, agentState.totalCostUsd, isCompactLayout],
|
||||
);
|
||||
const { beforeVoiceContent, footerRight } = useMemo(
|
||||
() => resolveContextWindowPlacement(contextWindowMeter, isMobile),
|
||||
[contextWindowMeter, isMobile],
|
||||
const { beforeVoiceContent, footerInlineContent } = useMemo(
|
||||
() => resolveContextWindowPlacement(contextWindowMeter, isCompactLayout),
|
||||
[contextWindowMeter, isCompactLayout],
|
||||
);
|
||||
|
||||
const githubSearchQueryTrimmed = githubSearchQuery.trim();
|
||||
@@ -1540,8 +1565,8 @@ export function Composer({
|
||||
);
|
||||
|
||||
const leftContent = useMemo(
|
||||
() => renderLeftContent({ agentControls, agentId, serverId, focusInput }),
|
||||
[agentId, focusInput, serverId, agentControls],
|
||||
() => renderLeftContent({ agentControls, agentId, serverId, focusInput, isCompactLayout }),
|
||||
[agentId, focusInput, serverId, agentControls, isCompactLayout],
|
||||
);
|
||||
|
||||
const handleAttachButtonRef = useCallback((node: View | null) => {
|
||||
@@ -1718,7 +1743,7 @@ export function Composer({
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{renderComposerFooter(footer, footerRight)}
|
||||
{renderComposerFooter(footer, footerInlineContent)}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -1760,6 +1785,10 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
md: -theme.spacing[3],
|
||||
},
|
||||
alignItems: "center",
|
||||
paddingBottom: {
|
||||
xs: 0,
|
||||
md: theme.spacing[2],
|
||||
},
|
||||
},
|
||||
footerContent: {
|
||||
width: "100%",
|
||||
@@ -1782,6 +1811,7 @@ 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: {
|
||||
@@ -1789,17 +1819,6 @@ 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%",
|
||||
|
||||
@@ -13,6 +13,7 @@ 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
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* 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";
|
||||
|
||||
@@ -26,28 +24,3 @@ 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: "2.18.0",
|
||||
version: "3",
|
||||
iconId: "cline",
|
||||
installLink: "https://cline.bot/cli",
|
||||
command: ["npx", "-y", "cline@2.18.0", "--acp"],
|
||||
command: ["npx", "-y", "cline@3", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "codebuddy-code",
|
||||
|
||||
@@ -15,6 +15,7 @@ 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"];
|
||||
|
||||
@@ -133,7 +134,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>
|
||||
<Text style={styles.logOutput} selectable dataSet={CODE_SURFACE_DATASET}>
|
||||
{daemonLogs?.contents?.length ? daemonLogs.contents : "(log file is empty)"}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -163,7 +164,7 @@ function DaemonCliStatusModal({
|
||||
snapPoints={CLI_STATUS_MODAL_SNAP_POINTS}
|
||||
>
|
||||
<View style={styles.modalBody}>
|
||||
<Text style={styles.logOutput} selectable>
|
||||
<Text style={styles.logOutput} selectable dataSet={CODE_SURFACE_DATASET}>
|
||||
{cliStatusOutput ?? ""}
|
||||
</Text>
|
||||
<View style={styles.modalActions}>
|
||||
|
||||
@@ -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}>
|
||||
<View style={DIFF_CONTENT_SPLIT_ROW_STYLE} dataSet={CODE_SURFACE_DATASET}>
|
||||
<SplitDiffColumn
|
||||
rows={rows}
|
||||
side="left"
|
||||
@@ -979,7 +979,7 @@ function DiffFileBody({
|
||||
|
||||
if (wrapLines) {
|
||||
return (
|
||||
<View style={styles.diffContent}>
|
||||
<View style={styles.diffContent} dataSet={CODE_SURFACE_DATASET}>
|
||||
<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}>
|
||||
<View style={DIFF_CONTENT_ROW_STYLE} dataSet={CODE_SURFACE_DATASET}>
|
||||
<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: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.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: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
color: theme.colors.foreground,
|
||||
userSelect: "text",
|
||||
},
|
||||
|
||||
@@ -34,7 +34,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("ensureAgentIsInitialized", () => {
|
||||
it("requests bounded canonical catch-up after the current cursor when authoritative history is loaded", () => {
|
||||
it("requests bounded projected 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: "canonical",
|
||||
projection: "projected",
|
||||
});
|
||||
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("after");
|
||||
});
|
||||
|
||||
it("requests a bounded canonical tail when no authoritative cursor is available", () => {
|
||||
it("requests a bounded projected 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: "canonical",
|
||||
projection: "projected",
|
||||
});
|
||||
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
|
||||
});
|
||||
@@ -107,7 +107,7 @@ describe("ensureAgentIsInitialized", () => {
|
||||
});
|
||||
|
||||
describe("refreshAgent", () => {
|
||||
it("fetches a bounded canonical tail after refreshing the agent", async () => {
|
||||
it("fetches a bounded projected 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: "canonical",
|
||||
projection: "projected",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,11 +20,14 @@ export function useContainerWidth(): {
|
||||
/**
|
||||
* Tracks only whether a container is narrower than a threshold.
|
||||
*/
|
||||
export function useContainerWidthBelow(threshold: number): {
|
||||
export function useContainerWidthBelow(
|
||||
threshold: number,
|
||||
options?: { initialIsBelow?: boolean },
|
||||
): {
|
||||
onLayout: (e: LayoutChangeEvent) => void;
|
||||
isBelow: boolean;
|
||||
} {
|
||||
const [isBelow, setIsBelow] = useState(true);
|
||||
const [isBelow, setIsBelow] = useState(options?.initialIsBelow ?? 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: "canonical",
|
||||
projection: "projected",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface LoadOlderAgentHistoryClient {
|
||||
direction: "before";
|
||||
cursor: { epoch: string; seq: number };
|
||||
limit: number;
|
||||
projection: "canonical";
|
||||
projection: "projected";
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -14,12 +14,20 @@ 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,
|
||||
@@ -35,10 +43,18 @@ 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,
|
||||
@@ -136,6 +152,21 @@ 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,8 +4,11 @@ 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,
|
||||
@@ -87,10 +90,8 @@ 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));
|
||||
});
|
||||
@@ -126,12 +127,8 @@ 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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -172,10 +169,8 @@ 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",
|
||||
});
|
||||
@@ -194,12 +189,8 @@ 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -234,3 +225,146 @@ 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,3 +1,4 @@
|
||||
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";
|
||||
@@ -15,12 +16,24 @@ 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 {
|
||||
@@ -33,6 +46,11 @@ 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 = {
|
||||
@@ -144,6 +162,31 @@ 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;
|
||||
}
|
||||
|
||||
@@ -171,6 +214,42 @@ export function parseTerminalScrollbackLines(value: unknown): number | null {
|
||||
);
|
||||
}
|
||||
|
||||
export function parseClampedFontSize(
|
||||
value: unknown,
|
||||
bounds: { min: number; max: number },
|
||||
): number | null {
|
||||
let numericValue = NaN;
|
||||
if (typeof value === "number") {
|
||||
numericValue = value;
|
||||
} else if (typeof value === "string" && value.trim().length > 0) {
|
||||
numericValue = Number(value);
|
||||
}
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(bounds.max, Math.max(bounds.min, Math.floor(numericValue)));
|
||||
}
|
||||
|
||||
export function sanitizeFontFamily(value: unknown): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return ""; // explicit empty = default
|
||||
}
|
||||
if (trimmed.length > MAX_FONT_FAMILY_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
if (/[;{}<>]/.test(trimmed)) {
|
||||
return null; // would break the web CSS font-family declaration
|
||||
}
|
||||
if ([...trimmed].some((char) => char.charCodeAt(0) <= 0x1f)) {
|
||||
return null; // control chars would corrupt the font-family string
|
||||
}
|
||||
return trimmed; // quotes/commas are legit in stacks
|
||||
}
|
||||
|
||||
async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): Promise<{
|
||||
manageBuiltInDaemon?: boolean;
|
||||
releaseChannel?: ReleaseChannel;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { SquarePen } from "lucide-react-native";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { memo, 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 { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { COMPACT_FORM_FACTOR_WIDTH, 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,8 +36,10 @@ 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,
|
||||
@@ -1095,42 +1097,52 @@ 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}>
|
||||
<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>
|
||||
{contentContainer}
|
||||
|
||||
<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}
|
||||
/>
|
||||
{composerSection}
|
||||
|
||||
{showHistorySyncOverlay ? (
|
||||
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
|
||||
@@ -1158,7 +1170,7 @@ function ChatAgentReadyContent({
|
||||
);
|
||||
}
|
||||
|
||||
function AgentStreamSection({
|
||||
const AgentStreamSection = memo(function AgentStreamSection({
|
||||
streamViewRef,
|
||||
serverId,
|
||||
agentId,
|
||||
@@ -1222,7 +1234,7 @@ function AgentStreamSection({
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function AgentComposerSection({
|
||||
agentId,
|
||||
@@ -1306,7 +1318,11 @@ function ActiveAgentComposer({
|
||||
onMessageSent: () => void;
|
||||
}) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
|
||||
COMPACT_FORM_FACTOR_WIDTH,
|
||||
{ initialIsBelow: isCompactFormFactor },
|
||||
);
|
||||
const paneContext = usePaneContext();
|
||||
const { workspaceId, tabId, retargetCurrentTab } = paneContext;
|
||||
const { archiveAgent } = useArchiveAgent();
|
||||
@@ -1344,14 +1360,14 @@ function ActiveAgentComposer({
|
||||
};
|
||||
openFileExplorerForCheckout({
|
||||
checkout,
|
||||
isCompact,
|
||||
isCompact: isCompactFormFactor,
|
||||
});
|
||||
setExplorerTabForCheckout({
|
||||
...checkout,
|
||||
tab: "changes",
|
||||
});
|
||||
},
|
||||
[isCompact, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
|
||||
[isCompactFormFactor, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
|
||||
);
|
||||
|
||||
const handleClientSlashCommand = useCallback(
|
||||
@@ -1403,14 +1419,19 @@ function ActiveAgentComposer({
|
||||
|
||||
const composerFooter = useMemo(
|
||||
() =>
|
||||
isCompact ? (
|
||||
<AgentModeControl serverId={serverId} agentId={agentId} placement="footer" />
|
||||
isCompactComposerLayout ? (
|
||||
<AgentModeControl
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
placement="footer"
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
) : undefined,
|
||||
[isCompact, serverId, agentId],
|
||||
[isCompactComposerLayout, serverId, agentId],
|
||||
);
|
||||
|
||||
return (
|
||||
<ReanimatedAnimated.View style={inputAreaStyle}>
|
||||
<ReanimatedAnimated.View style={inputAreaStyle} onLayout={onInputAreaLayout}>
|
||||
<SubagentsTrack
|
||||
rows={subagentRows}
|
||||
onOpenSubagent={handleOpenSubagent}
|
||||
@@ -1438,6 +1459,7 @@ function ActiveAgentComposer({
|
||||
onMessageSent={onMessageSent}
|
||||
onClientSlashCommand={handleClientSlashCommand}
|
||||
footer={composerFooter}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
);
|
||||
|
||||
@@ -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 style={styles.logText}>
|
||||
<Text selectable dataSet={CODE_SURFACE_DATASET} 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 style={styles.logText}>
|
||||
<Text selectable dataSet={CODE_SURFACE_DATASET} style={styles.logText}>
|
||||
{log}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
@@ -540,7 +540,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[3],
|
||||
},
|
||||
logText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.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, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { buildHostAgentDetailRoute, buildSettingsHostSectionRoute } 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(buildSettingsHostRoute(serverId));
|
||||
router.push(buildSettingsHostSectionRoute(serverId, "providers"));
|
||||
}, [router, serverId]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import type { ComponentType, ReactElement, ReactNode } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Pressable,
|
||||
@@ -16,12 +24,14 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Buffer } from "buffer";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
ChevronDown,
|
||||
Monitor,
|
||||
Settings,
|
||||
Palette,
|
||||
Server,
|
||||
Network,
|
||||
Workflow,
|
||||
Boxes,
|
||||
Keyboard,
|
||||
Stethoscope,
|
||||
Info,
|
||||
@@ -35,6 +45,7 @@ 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,
|
||||
@@ -44,7 +55,6 @@ import {
|
||||
type ServiceUrlBehavior,
|
||||
type Settings as EffectiveSettings,
|
||||
} from "@/hooks/use-settings";
|
||||
import { THEME_SWATCHES } from "@/styles/theme";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
isHostRuntimeConnected,
|
||||
@@ -69,9 +79,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";
|
||||
@@ -81,7 +91,12 @@ 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 { HostPage, HostRenameButton } from "@/screens/settings/host-page";
|
||||
import {
|
||||
HostConnectionsPage,
|
||||
HostDaemonPage,
|
||||
HostOrchestrationPage,
|
||||
HostProvidersPage,
|
||||
} from "@/screens/settings/host-page";
|
||||
import ProjectsScreen from "@/screens/projects-screen";
|
||||
import ProjectSettingsScreen from "@/screens/project-settings-screen";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -90,8 +105,9 @@ import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import {
|
||||
buildHostOpenProjectRoute,
|
||||
buildProjectsSettingsRoute,
|
||||
buildSettingsHostRoute,
|
||||
buildSettingsHostSectionRoute,
|
||||
buildSettingsSectionRoute,
|
||||
type HostSectionSlug,
|
||||
type SettingsSectionSlug,
|
||||
} from "@/utils/host-routes";
|
||||
import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
@@ -103,7 +119,7 @@ import { navigateToLastWorkspace } from "@/stores/navigation-active-workspace-st
|
||||
export type SettingsView =
|
||||
| { kind: "root" }
|
||||
| { kind: "section"; section: SettingsSectionSlug }
|
||||
| { kind: "host"; serverId: string }
|
||||
| { kind: "host"; serverId: string; section: HostSectionSlug }
|
||||
| { kind: "projects" }
|
||||
| { kind: "project"; projectKey: string };
|
||||
|
||||
@@ -116,6 +132,7 @@ 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 },
|
||||
@@ -123,45 +140,22 @@ const SIDEBAR_SECTION_ITEMS: SidebarSectionItem[] = [
|
||||
{ id: "about", label: "About", icon: Info },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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} />;
|
||||
}
|
||||
interface HostSectionItem {
|
||||
id: HostSectionSlug;
|
||||
label: string;
|
||||
icon: ComponentType<{ size: number; color: string }>;
|
||||
}
|
||||
|
||||
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} />;
|
||||
}
|
||||
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 themeTriggerStyle({ pressed }: PressableStateCallbackType) {
|
||||
return [styles.themeTrigger, pressed && { opacity: 0.85 }];
|
||||
@@ -179,16 +173,6 @@ 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 = [
|
||||
@@ -216,41 +200,11 @@ 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;
|
||||
@@ -275,13 +229,11 @@ 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),
|
||||
@@ -312,41 +264,6 @@ 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}>
|
||||
@@ -709,6 +626,26 @@ 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;
|
||||
@@ -751,6 +688,49 @@ 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;
|
||||
@@ -782,83 +762,213 @@ function SidebarProjectsButton({ isSelected, onSelect }: SidebarProjectsButtonPr
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarHostItemProps {
|
||||
// Sentinel option id for the "Add host" row appended to the picker list.
|
||||
const ADD_HOST_OPTION_ID = "__add_host__";
|
||||
|
||||
interface HostPickerOptionProps {
|
||||
serverId: string;
|
||||
label: string;
|
||||
isSelected: boolean;
|
||||
isLocal: boolean;
|
||||
onSelect: (serverId: string) => void;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
function SidebarHostItem({ serverId, label, isSelected, isLocal, onSelect }: SidebarHostItemProps) {
|
||||
function HostPickerOption({
|
||||
serverId,
|
||||
label,
|
||||
isLocal,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
}: HostPickerOptionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
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],
|
||||
const leadingSlot = useMemo(
|
||||
() => <Server size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
||||
[theme.iconSize.sm, theme.colors.foregroundMuted],
|
||||
);
|
||||
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 ? (
|
||||
// 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 ? (
|
||||
<Text style={sidebarStyles.localMarker} testID="settings-host-local-marker">
|
||||
Local
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
) : 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 = 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 sortedHosts = useSortedHosts(hosts, localServerId);
|
||||
const hasHosts = sortedHosts.length > 0;
|
||||
const isDesktopApp = isElectronRuntime();
|
||||
const items = SIDEBAR_SECTION_ITEMS.filter((item) => !item.desktopOnly || isDesktopApp);
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -872,7 +982,7 @@ function SettingsSidebar({
|
||||
[insets.top, isDesktop],
|
||||
);
|
||||
const selectedSectionId = view.kind === "section" ? view.section : null;
|
||||
const selectedServerId = view.kind === "host" ? view.serverId : null;
|
||||
const selectedHostSection = view.kind === "host" ? view.section : null;
|
||||
const isProjectsSelected = view.kind === "projects" || view.kind === "project";
|
||||
const paddingTopStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
|
||||
|
||||
@@ -893,6 +1003,7 @@ function SettingsSidebar({
|
||||
/>
|
||||
) : null}
|
||||
<View style={sidebarStyles.list}>
|
||||
<Text style={sidebarStyles.groupLabel}>App</Text>
|
||||
{items.map((item) => (
|
||||
<Fragment key={item.id}>
|
||||
<SidebarSectionButton
|
||||
@@ -909,30 +1020,43 @@ function SettingsSidebar({
|
||||
))}
|
||||
</View>
|
||||
<SidebarSeparator />
|
||||
<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}
|
||||
{hasHosts ? (
|
||||
<View style={sidebarStyles.list}>
|
||||
<Text style={sidebarStyles.groupLabel}>Host</Text>
|
||||
<HostPicker
|
||||
activeServerId={activeHostServerId}
|
||||
sortedHosts={sortedHosts}
|
||||
localServerId={localServerId}
|
||||
onSelectHost={onSelectHost}
|
||||
onAddHost={onAddHost}
|
||||
/>
|
||||
))}
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -967,15 +1091,17 @@ 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);
|
||||
|
||||
const handleThemeChange = useCallback(
|
||||
(nextTheme: AppSettings["theme"]) => {
|
||||
void updateSettings({ theme: nextTheme });
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
// 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 handleSendBehaviorChange = useCallback(
|
||||
(behavior: SendBehavior) => {
|
||||
@@ -1055,7 +1181,7 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
|
||||
const handleHostAdded = useCallback(
|
||||
({ serverId }: { serverId: string }) => {
|
||||
const target = buildSettingsHostRoute(serverId);
|
||||
const target = buildSettingsHostSectionRoute(serverId, "connections");
|
||||
if (isCompactLayout) {
|
||||
router.push(target);
|
||||
} else {
|
||||
@@ -1077,16 +1203,34 @@ 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 target = buildSettingsHostRoute(serverId);
|
||||
const section: HostSectionSlug = view.kind === "host" ? view.section : "connections";
|
||||
const target = buildSettingsHostSectionRoute(serverId, section);
|
||||
if (isCompactLayout) {
|
||||
router.push(target);
|
||||
} else {
|
||||
router.replace(target);
|
||||
}
|
||||
},
|
||||
[isCompactLayout, router],
|
||||
[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],
|
||||
);
|
||||
|
||||
const handleSelectProjects = useCallback(() => {
|
||||
@@ -1140,13 +1284,9 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
titleAccessory?: ReactNode;
|
||||
} | null => {
|
||||
if (view.kind === "host") {
|
||||
const host = hosts.find((h) => h.serverId === view.serverId);
|
||||
if (!host) return null;
|
||||
return {
|
||||
title: host.label,
|
||||
Icon: Server,
|
||||
titleAccessory: <HostRenameButton host={host} />,
|
||||
};
|
||||
const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section);
|
||||
if (!item) return null;
|
||||
return { title: item.label, Icon: item.icon };
|
||||
}
|
||||
if (view.kind === "section") {
|
||||
const item = SIDEBAR_SECTION_ITEMS.find((s) => s.id === view.section);
|
||||
@@ -1161,7 +1301,16 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
|
||||
const content = (() => {
|
||||
if (view.kind === "host") {
|
||||
return <HostPage serverId={view.serverId} onHostRemoved={handleHostRemoved} />;
|
||||
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} />;
|
||||
}
|
||||
}
|
||||
if (view.kind === "projects") {
|
||||
return <ProjectsScreen view={view} />;
|
||||
@@ -1176,12 +1325,13 @@ 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":
|
||||
@@ -1251,10 +1401,12 @@ 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>
|
||||
@@ -1293,10 +1445,12 @@ 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}>
|
||||
@@ -1448,6 +1602,13 @@ 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",
|
||||
@@ -1472,11 +1633,25 @@ 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],
|
||||
paddingVertical: 2,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,597 @@
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,176 @@
|
||||
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"));
|
||||
});
|
||||
});
|
||||
105
packages/app/src/screens/settings/appearance/apply-appearance.ts
Normal file
105
packages/app/src/screens/settings/appearance/apply-appearance.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// 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 {}
|
||||
@@ -0,0 +1,33 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// 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]);
|
||||
@@ -74,25 +74,30 @@ function formatDaemonVersionBadge(version: string | null): string | null {
|
||||
const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" };
|
||||
const REMOVE_HOST_HEADER: SheetHeader = { title: "Remove host" };
|
||||
|
||||
export interface HostPageProps {
|
||||
serverId: string;
|
||||
onHostRemoved?: () => void;
|
||||
function useHostProfile(serverId: string): HostProfile | null {
|
||||
const daemons = useHosts();
|
||||
return daemons.find((entry) => entry.serverId === serverId) ?? null;
|
||||
}
|
||||
|
||||
export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
|
||||
const daemons = useHosts();
|
||||
const host = 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 { 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;
|
||||
@@ -117,8 +122,6 @@ export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
|
||||
}
|
||||
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 }],
|
||||
@@ -130,46 +133,125 @@ export function HostPage({ serverId, onHostRemoved }: HostPageProps) {
|
||||
);
|
||||
const statusTextStyle = useMemo(() => [styles.statusText, { color: statusColor }], [statusColor]);
|
||||
|
||||
if (!host) {
|
||||
return (
|
||||
<View testID={`settings-host-page-${serverId}`}>
|
||||
<View style={EMPTY_CARD_STYLE}>
|
||||
<Text style={styles.emptyText}>Host not found</Text>
|
||||
</View>
|
||||
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 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}
|
||||
|
||||
<View>
|
||||
<HostConnectionError serverId={serverId} />
|
||||
<ConnectionsSection host={host} />
|
||||
{isLocalDaemon ? (
|
||||
<SettingsSection title="Pair devices">
|
||||
<PairDeviceRow />
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
<DaemonSection host={host} isLocalDaemon={isLocalDaemon} />
|
||||
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>
|
||||
) : (
|
||||
<View style={EMPTY_CARD_STYLE}>
|
||||
<Text style={styles.emptyText}>Connect to this host to manage orchestration</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} />
|
||||
|
||||
{isLocalDaemon ? <LocalDaemonSection /> : null}
|
||||
|
||||
<RemoveHostSection host={host} onRemoved={onHostRemoved} />
|
||||
</View>
|
||||
@@ -337,7 +419,7 @@ function ConnectionRow({
|
||||
if (latencyLoading) return "...";
|
||||
if (latencyError) return "Timeout";
|
||||
if (latencyMs != null) return formatLatency(latencyMs);
|
||||
return "\u2014";
|
||||
return "—";
|
||||
})();
|
||||
const latencyColor = latencyError ? theme.colors.palette.red[300] : theme.colors.foregroundMuted;
|
||||
|
||||
@@ -378,23 +460,6 @@ 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);
|
||||
@@ -612,7 +677,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}>Added a system prompt to all agents</Text>
|
||||
<Text style={settingsStyles.rowHint}>Adds a system prompt to all agents</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -803,6 +868,18 @@ 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: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
},
|
||||
logsMeta: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -257,7 +257,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
logsText: {
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
@@ -417,7 +417,9 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
GitHub and include the logs below.
|
||||
</Text>
|
||||
|
||||
<Text style={styles.errorMessage}>{bootstrapState.splashError}</Text>
|
||||
<Text dataSet={CODE_SURFACE_DATASET} style={styles.errorMessage}>
|
||||
{bootstrapState.splashError}
|
||||
</Text>
|
||||
|
||||
{daemonLogs?.logPath ? <Text style={styles.logsMeta}>{daemonLogs.logPath}</Text> : null}
|
||||
|
||||
@@ -427,7 +429,7 @@ export function StartupSplashScreen({ bootstrapState }: StartupSplashScreenProps
|
||||
contentContainerStyle={styles.logsContent}
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<Text selectable style={styles.logsText}>
|
||||
<Text dataSet={CODE_SURFACE_DATASET} selectable style={styles.logsText}>
|
||||
{logsText}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
|
||||
@@ -62,6 +62,7 @@ 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;
|
||||
@@ -148,7 +149,6 @@ 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,7 +255,6 @@ function TabChip({
|
||||
presentation,
|
||||
tooltipLabel,
|
||||
resolvedTab,
|
||||
setHoveredTabKey,
|
||||
setHoveredCloseTabKey,
|
||||
onNavigateTab,
|
||||
onCloseTab,
|
||||
@@ -273,7 +272,6 @@ 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;
|
||||
@@ -311,13 +309,11 @@ 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);
|
||||
@@ -328,14 +324,12 @@ function TabChip({
|
||||
}, []);
|
||||
|
||||
const handleCloseButtonHoverIn = useCallback(() => {
|
||||
setHoveredTabKey(tab.key);
|
||||
setHoveredCloseTabKey(tab.key);
|
||||
}, [setHoveredTabKey, setHoveredCloseTabKey, tab.key]);
|
||||
}, [setHoveredCloseTabKey, tab.key]);
|
||||
|
||||
const handleCloseButtonHoverOut = useCallback(() => {
|
||||
setHoveredTabKey((current) => (current === tab.key ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === tab.key ? null : current));
|
||||
}, [setHoveredTabKey, setHoveredCloseTabKey, tab.key]);
|
||||
}, [setHoveredCloseTabKey, tab.key]);
|
||||
|
||||
const handleCloseButtonPress = useCallback(
|
||||
(event: { stopPropagation?: () => void }) => {
|
||||
@@ -466,7 +460,6 @@ export function WorkspaceDesktopTabsRow({
|
||||
tabs,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
setHoveredTabKey,
|
||||
setHoveredCloseTabKey,
|
||||
onNavigateTab,
|
||||
onCloseTab,
|
||||
@@ -613,7 +606,6 @@ export function WorkspaceDesktopTabsRow({
|
||||
resolvedTabWidth={resolvedTabWidth}
|
||||
showLabel={showLabel}
|
||||
showCloseButton={shouldShowCloseButton}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
@@ -640,7 +632,6 @@ export function WorkspaceDesktopTabsRow({
|
||||
onReloadAgent,
|
||||
onRenameTab,
|
||||
setHoveredCloseTabKey,
|
||||
setHoveredTabKey,
|
||||
tabDropPreviewIndex,
|
||||
tabs.length,
|
||||
],
|
||||
@@ -656,7 +647,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
[layout.requiresHorizontalScrollFallback],
|
||||
);
|
||||
|
||||
return (
|
||||
const row = (
|
||||
<View
|
||||
style={styles.tabsContainer}
|
||||
testID="workspace-tabs-row"
|
||||
@@ -787,8 +778,9 @@ export function WorkspaceDesktopTabsRow({
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return <RenderProfile id="WorkspaceDesktopTabsRow">{row}</RenderProfile>;
|
||||
}
|
||||
function ResolvedDesktopTabChip({
|
||||
item,
|
||||
isFocused,
|
||||
@@ -807,7 +799,6 @@ function ResolvedDesktopTabChip({
|
||||
resolvedTabWidth,
|
||||
showLabel,
|
||||
showCloseButton,
|
||||
setHoveredTabKey,
|
||||
setHoveredCloseTabKey,
|
||||
onNavigateTab,
|
||||
onCloseTab,
|
||||
@@ -832,7 +823,6 @@ 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;
|
||||
@@ -896,7 +886,6 @@ function ResolvedDesktopTabChip({
|
||||
presentation={presentation}
|
||||
tooltipLabel={tooltipLabel}
|
||||
resolvedTab={resolvedTab}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
|
||||
@@ -6,9 +6,11 @@ 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 {
|
||||
@@ -72,6 +74,35 @@ 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({
|
||||
@@ -83,10 +114,14 @@ export function WorkspacePaneContent({
|
||||
);
|
||||
|
||||
return (
|
||||
<PaneProvider value={paneContextValue}>
|
||||
<PaneFocusProvider value={paneFocusValue}>
|
||||
<Component key={key} />
|
||||
</PaneFocusProvider>
|
||||
</PaneProvider>
|
||||
<RenderProfile
|
||||
id={`WorkspacePaneContent:${paneContextValue.target.kind}:${paneContextValue.tabId}`}
|
||||
>
|
||||
<PaneProvider value={stablePaneContextValue}>
|
||||
<PaneFocusProvider value={paneFocusValue}>
|
||||
<Component key={key} />
|
||||
</PaneFocusProvider>
|
||||
</PaneProvider>
|
||||
</RenderProfile>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@ 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";
|
||||
@@ -734,16 +735,43 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
|
||||
: styles.mobileMountedTabSlotHidden;
|
||||
|
||||
return (
|
||||
<View style={slotStyle} pointerEvents={isVisible ? "auto" : "none"}>
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</View>
|
||||
<RenderProfile id={`MobileMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
|
||||
<View style={slotStyle} pointerEvents={isVisible ? "auto" : "none"}>
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</View>
|
||||
</RenderProfile>
|
||||
);
|
||||
});
|
||||
|
||||
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(() => {
|
||||
@@ -770,7 +798,11 @@ function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
|
||||
return tabDescriptorMap;
|
||||
}
|
||||
|
||||
export function WorkspaceScreen({ serverId, workspaceId, isRouteFocused }: WorkspaceScreenProps) {
|
||||
export const WorkspaceScreen = memo(function WorkspaceScreen({
|
||||
serverId,
|
||||
workspaceId,
|
||||
isRouteFocused,
|
||||
}: WorkspaceScreenProps) {
|
||||
const navigationFocused = useIsFocused();
|
||||
const effectiveRouteFocused = isRouteFocused ?? navigationFocused;
|
||||
|
||||
@@ -783,7 +815,7 @@ export function WorkspaceScreen({ serverId, workspaceId, isRouteFocused }: Works
|
||||
/>
|
||||
</ExplorerSidebarAnimationProvider>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
interface UseCloseTabsResult {
|
||||
closingTabIds: Set<string>;
|
||||
@@ -1657,10 +1689,6 @@ 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,
|
||||
@@ -1712,11 +1740,6 @@ function WorkspaceScreenContent({
|
||||
[isExplorerOpen],
|
||||
);
|
||||
|
||||
const explorerOpenGesture = useExplorerOpenGesture({
|
||||
enabled: isMobile && canOpenExplorerFromAgentView,
|
||||
onOpen: openExplorerForWorkspace,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRouteFocused || isWeb || !isExplorerOpen) {
|
||||
return;
|
||||
@@ -2178,7 +2201,31 @@ function WorkspaceScreenContent({
|
||||
],
|
||||
);
|
||||
|
||||
const [_hoveredTabKey, setHoveredTabKey] = useState<string | null>(null);
|
||||
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 [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(null);
|
||||
const { handleRenameTab, renamingTab, handleRenameModalSubmit, handleRenameModalClose } =
|
||||
useWorkspaceTabRename({
|
||||
@@ -2300,7 +2347,6 @@ function WorkspaceScreenContent({
|
||||
}
|
||||
|
||||
removeTerminalFromCache(terminalId);
|
||||
setHoveredTabKey((current) => (current === tabId ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
||||
if (persistenceKey) {
|
||||
closeWorkspaceTabWithCleanup({
|
||||
@@ -2349,7 +2395,6 @@ function WorkspaceScreenContent({
|
||||
}
|
||||
}
|
||||
|
||||
setHoveredTabKey((current) => (current === tabId ? null : current));
|
||||
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
||||
if (persistenceKey) {
|
||||
closeWorkspaceTabWithCleanup({
|
||||
@@ -2374,7 +2419,6 @@ 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 });
|
||||
@@ -2465,7 +2509,7 @@ function WorkspaceScreenContent({
|
||||
const currentCursor = sessionState?.agentTimelineCursor.get(agentId);
|
||||
await client.fetchAgentTimeline(agentId, {
|
||||
direction: "tail",
|
||||
projection: "canonical",
|
||||
projection: "projected",
|
||||
...(currentCursor
|
||||
? { cursor: { epoch: currentCursor.epoch, seq: currentCursor.endSeq } }
|
||||
: {}),
|
||||
@@ -2556,7 +2600,6 @@ 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],
|
||||
@@ -2851,26 +2894,19 @@ function WorkspaceScreenContent({
|
||||
retargetWorkspaceTab(persistenceKey, input.tab.tabId, target);
|
||||
},
|
||||
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => {
|
||||
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 });
|
||||
handleOpenWorkspaceFileFromPane({
|
||||
request,
|
||||
paneId: input.paneId,
|
||||
parentTabId: input.tab.tabId,
|
||||
focusPaneBeforeOpen: input.focusPaneBeforeOpen,
|
||||
});
|
||||
},
|
||||
onOpenImportSheet: openImportSheet,
|
||||
}),
|
||||
[
|
||||
handleCloseTabById,
|
||||
handleOpenFileFromChat,
|
||||
handleOpenFileFromChatInSidePane,
|
||||
focusWorkspacePane,
|
||||
handleOpenWorkspaceFileFromPane,
|
||||
navigateToTabId,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
@@ -3193,9 +3229,9 @@ function WorkspaceScreenContent({
|
||||
`${WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX}:${normalizedServerId}:${normalizedWorkspaceId}`,
|
||||
[normalizedServerId, normalizedWorkspaceId],
|
||||
);
|
||||
const desktopContent = useMemo(() => {
|
||||
const desktopSplitContent = useMemo(() => {
|
||||
if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) {
|
||||
return content;
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SplitContainer
|
||||
@@ -3207,7 +3243,6 @@ function WorkspaceScreenContent({
|
||||
isWorkspaceFocused={isRouteFocused}
|
||||
uiTabs={uiTabs}
|
||||
hoveredCloseTabKey={hoveredCloseTabKey}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
closingTabIds={closingTabIds}
|
||||
onNavigateTab={navigateToTabId}
|
||||
@@ -3234,7 +3269,6 @@ function WorkspaceScreenContent({
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
content,
|
||||
canRenderDesktopPaneSplits,
|
||||
workspaceLayout,
|
||||
persistenceKey,
|
||||
@@ -3267,6 +3301,7 @@ function WorkspaceScreenContent({
|
||||
handleReorderTabsInPane,
|
||||
renderSplitPaneEmptyState,
|
||||
]);
|
||||
const desktopContent = desktopSplitContent ?? content;
|
||||
|
||||
const workspaceCenterColumn = (
|
||||
<View style={styles.centerColumn}>
|
||||
@@ -3343,7 +3378,6 @@ function WorkspaceScreenContent({
|
||||
tabs={desktopTabRowItems}
|
||||
normalizedServerId={normalizedServerId}
|
||||
normalizedWorkspaceId={normalizedWorkspaceId}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={navigateToTabId}
|
||||
onCloseTab={handleCloseTabById}
|
||||
@@ -3369,12 +3403,9 @@ function WorkspaceScreenContent({
|
||||
|
||||
<View style={styles.centerContent}>
|
||||
{isMobile ? (
|
||||
<GestureDetector
|
||||
gesture={explorerOpenGesture}
|
||||
touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}
|
||||
>
|
||||
<View style={styles.content}>{content}</View>
|
||||
</GestureDetector>
|
||||
<MobileExplorerOpenGestureSurface onOpenExplorer={openExplorerForWorkspace}>
|
||||
{content}
|
||||
</MobileExplorerOpenGestureSurface>
|
||||
) : (
|
||||
<View style={styles.content}>{desktopContent}</View>
|
||||
)}
|
||||
@@ -3385,44 +3416,46 @@ function WorkspaceScreenContent({
|
||||
return (
|
||||
gatedWorkspaceScreen ?? (
|
||||
<WorkspaceFocusProvider workspaceKey={persistenceKey}>
|
||||
<View style={containerStyle}>
|
||||
<WorkspaceDocumentTitleEffectSlot
|
||||
tab={activeTabDescriptor}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
isRouteFocused={isRouteFocused}
|
||||
/>
|
||||
<View style={styles.threePaneRow}>
|
||||
<FloatingPanelPortalHostNameProvider hostName={workspaceFloatingPanelPortalHostName}>
|
||||
{workspaceCenterColumn}
|
||||
</FloatingPanelPortalHostNameProvider>
|
||||
<RenderProfile id="WorkspaceScreenContent">
|
||||
<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}
|
||||
{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}
|
||||
/>
|
||||
</View>
|
||||
<ImportSessionSheet
|
||||
visible={isImportSheetVisible}
|
||||
client={client}
|
||||
serverId={normalizedServerId}
|
||||
cwd={workspaceDirectory}
|
||||
onClose={closeImportSheet}
|
||||
onImportedAgent={handleImportedAgent}
|
||||
/>
|
||||
<WorkspaceTabRenameModal
|
||||
renamingTab={renamingTab}
|
||||
onSubmit={handleRenameModalSubmit}
|
||||
onClose={handleRenameModalClose}
|
||||
/>
|
||||
</View>
|
||||
</RenderProfile>
|
||||
</WorkspaceFocusProvider>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 {
|
||||
@@ -208,7 +209,6 @@ 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
packages/app/src/stores/workspace-layout-constants.ts
Normal file
1
packages/app/src/stores/workspace-layout-constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const MIN_SPLIT_SIZE = 0.1;
|
||||
7
packages/app/src/styles/code-surface.ts
Normal file
7
packages/app/src/styles/code-surface.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// 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 { createMarkdownStyles } from "./markdown-styles";
|
||||
import { createCompactMarkdownStyles, createMarkdownStyles } from "./markdown-styles";
|
||||
import { darkTheme } from "./theme";
|
||||
|
||||
describe("createMarkdownStyles", () => {
|
||||
@@ -69,4 +69,28 @@ 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),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Theme } from "./theme";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const webSelectableTextStyle = isWeb ? { userSelect: "text" as const } : {};
|
||||
@@ -21,7 +20,9 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
...webSelectableTextStyle,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 22,
|
||||
// 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),
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
width: "100%" as const,
|
||||
@@ -169,8 +170,9 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
paddingVertical: 2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 0,
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: theme.fontSize.base - 3,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
lineHeight: Math.round(theme.fontSize.code * 1.45),
|
||||
},
|
||||
|
||||
code_block: {
|
||||
@@ -179,7 +181,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
color: theme.colors.foreground,
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
marginVertical: theme.spacing[2],
|
||||
},
|
||||
@@ -192,7 +194,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
fontFamily: Fonts.mono,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
marginVertical: theme.spacing[3],
|
||||
},
|
||||
@@ -388,7 +390,7 @@ export function createCompactMarkdownStyles(theme: Theme) {
|
||||
|
||||
code_inline: {
|
||||
...baseStyles.code_inline,
|
||||
fontSize: theme.fontSize.sm - 3,
|
||||
fontSize: theme.fontSize.code,
|
||||
},
|
||||
|
||||
code_block: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Platform } from "react-native";
|
||||
import { darkHighlightColors, lightHighlightColors } from "@getpaseo/highlight";
|
||||
|
||||
export const baseColors = {
|
||||
@@ -485,16 +486,48 @@ export const OPACITY = {
|
||||
100: 1,
|
||||
} as const;
|
||||
|
||||
const commonTheme = {
|
||||
// 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 = {
|
||||
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,6 +14,7 @@ vi.mock("@xterm/addon-webgl", () => ({
|
||||
interface TerminalSize {
|
||||
rows: number;
|
||||
cols: number;
|
||||
shouldClaim: boolean;
|
||||
}
|
||||
|
||||
interface TerminalKeyRecord {
|
||||
@@ -198,6 +199,15 @@ 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 });
|
||||
@@ -220,6 +230,7 @@ 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,11 +86,15 @@ interface StubTerminal {
|
||||
resize?: (cols: number, rows: number) => void;
|
||||
focus: () => void;
|
||||
refresh?: (start: number, end: number) => void;
|
||||
options?: { theme?: unknown; scrollback?: number };
|
||||
options?: { theme?: unknown; scrollback?: number; fontFamily?: string; fontSize?: number };
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
}
|
||||
|
||||
interface RuntimeFitProbe {
|
||||
fitAndEmitResize: (input?: { force?: boolean; shouldClaim?: boolean }) => void;
|
||||
}
|
||||
|
||||
function createRuntimeWithTerminal(): {
|
||||
runtime: TerminalEmulatorRuntime;
|
||||
terminal: StubTerminal & {
|
||||
@@ -328,14 +332,13 @@ describe("terminal-emulator-runtime", () => {
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
const fitAndEmitResize = vi.fn();
|
||||
|
||||
(runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize =
|
||||
fitAndEmitResize;
|
||||
(runtime as unknown as RuntimeFitProbe).fitAndEmitResize = fitAndEmitResize;
|
||||
|
||||
runtime.resize();
|
||||
runtime.resize({ force: true });
|
||||
|
||||
expect(fitAndEmitResize).toHaveBeenNthCalledWith(1, false);
|
||||
expect(fitAndEmitResize).toHaveBeenNthCalledWith(2, true);
|
||||
expect(fitAndEmitResize).toHaveBeenNthCalledWith(1, undefined);
|
||||
expect(fitAndEmitResize).toHaveBeenNthCalledWith(2, { force: true });
|
||||
});
|
||||
|
||||
it("updates terminal theme without remounting", () => {
|
||||
@@ -381,12 +384,36 @@ describe("terminal-emulator-runtime", () => {
|
||||
expect(refresh).toHaveBeenCalledWith(0, 11);
|
||||
});
|
||||
|
||||
it("forces a refit when the page becomes visible again", () => {
|
||||
it("updates terminal font without remounting", () => {
|
||||
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 { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize =
|
||||
fitAndEmitResize;
|
||||
(runtime as unknown as RuntimeFitProbe).fitAndEmitResize = fitAndEmitResize;
|
||||
(globalThis as { document?: { visibilityState?: string } }).document = {
|
||||
visibilityState: "visible",
|
||||
};
|
||||
@@ -397,15 +424,14 @@ describe("terminal-emulator-runtime", () => {
|
||||
}
|
||||
).handleVisibilityRestore();
|
||||
|
||||
expect(fitAndEmitResize).toHaveBeenCalledWith(true);
|
||||
expect(fitAndEmitResize).toHaveBeenCalledWith({ force: true, shouldClaim: false });
|
||||
});
|
||||
|
||||
it("does not refit while the page is still hidden", () => {
|
||||
const runtime = new TerminalEmulatorRuntime();
|
||||
const fitAndEmitResize = vi.fn();
|
||||
|
||||
(runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize =
|
||||
fitAndEmitResize;
|
||||
(runtime as unknown as RuntimeFitProbe).fitAndEmitResize = fitAndEmitResize;
|
||||
(globalThis as { document?: { visibilityState?: string } }).document = {
|
||||
visibilityState: "hidden",
|
||||
};
|
||||
|
||||
@@ -37,11 +37,13 @@ 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 }) => Promise<void> | void;
|
||||
onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise<void> | void;
|
||||
onTerminalKey?: (input: {
|
||||
key: string;
|
||||
ctrl: boolean;
|
||||
@@ -68,7 +70,6 @@ interface TerminalEmulatorRuntimeDisposables {
|
||||
removeWindowFocus: () => void;
|
||||
removeDocumentVisibilityChange: () => void;
|
||||
removeVisualViewportResize: () => void;
|
||||
clearFitInterval: () => void;
|
||||
clearFitTimeouts: () => void;
|
||||
removeFontListeners: () => void;
|
||||
removeTouchListeners: () => void;
|
||||
@@ -108,6 +109,7 @@ 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);
|
||||
@@ -148,6 +150,17 @@ 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,
|
||||
@@ -164,7 +177,8 @@ export class TerminalEmulatorRuntime {
|
||||
};
|
||||
private terminal: Terminal | null = null;
|
||||
private fitAddon: FitAddon | null = null;
|
||||
private fitAndEmitResize: ((force: boolean) => void) | null = null;
|
||||
private fitAndEmitResize: ((input?: { force?: boolean; shouldClaim?: boolean }) => void) | null =
|
||||
null;
|
||||
private lastSize: { rows: number; cols: number } | null = null;
|
||||
private cleanup: (() => void) | null = null;
|
||||
private outputOperations: TerminalOutputOperation[] = [];
|
||||
@@ -181,10 +195,10 @@ export class TerminalEmulatorRuntime {
|
||||
return;
|
||||
}
|
||||
|
||||
this.fitAndEmitResize?.(true);
|
||||
this.fitAndEmitResize?.({ force: true, shouldClaim: false });
|
||||
if (typeof window.requestAnimationFrame === "function") {
|
||||
window.requestAnimationFrame(() => {
|
||||
this.fitAndEmitResize?.(true);
|
||||
this.fitAndEmitResize?.({ force: true, shouldClaim: false });
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -210,8 +224,8 @@ export class TerminalEmulatorRuntime {
|
||||
convertEol: false,
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar",
|
||||
fontFamily: DEFAULT_TERMINAL_FONT_FAMILY,
|
||||
fontSize: 13,
|
||||
fontFamily: resolveTerminalFontFamily(input.fontFamily),
|
||||
fontSize: resolveTerminalFontSize(input.fontSize),
|
||||
lineHeight: 1.0,
|
||||
macOptionIsMeta: true,
|
||||
minimumContrastRatio: 1,
|
||||
@@ -281,7 +295,7 @@ export class TerminalEmulatorRuntime {
|
||||
webglAddon = null;
|
||||
disposeImageAddon();
|
||||
// WebGL and DOM renderers can have different cell dimensions.
|
||||
this.fitAndEmitResize?.(true);
|
||||
this.fitAndEmitResize?.({ force: true, shouldClaim: false });
|
||||
};
|
||||
|
||||
// Browser xterm is a renderer only; it never replies to terminal protocol queries.
|
||||
@@ -319,7 +333,7 @@ export class TerminalEmulatorRuntime {
|
||||
imageAddon = new ImageAddon();
|
||||
terminal.loadAddon(imageAddon);
|
||||
registerProtocolQuerySuppression();
|
||||
this.fitAndEmitResize?.(true);
|
||||
this.fitAndEmitResize?.({ force: true, shouldClaim: false });
|
||||
} catch {
|
||||
disposeWebglRenderer();
|
||||
}
|
||||
@@ -336,7 +350,9 @@ export class TerminalEmulatorRuntime {
|
||||
this.fitAddon = fitAddon;
|
||||
window.__paseoTerminal = terminal;
|
||||
|
||||
const fitAndEmitResize = (force: boolean): void => {
|
||||
const fitAndEmitResize = (resizeInput?: { force?: boolean; shouldClaim?: boolean }): void => {
|
||||
const force = resizeInput?.force ?? false;
|
||||
const shouldClaim = resizeInput?.shouldClaim ?? true;
|
||||
const currentTerminal = this.terminal;
|
||||
const currentFitAddon = this.fitAddon;
|
||||
if (!currentTerminal || !currentFitAddon) {
|
||||
@@ -365,11 +381,12 @@ export class TerminalEmulatorRuntime {
|
||||
this.callbacks.onResize?.({
|
||||
rows: nextRows,
|
||||
cols: nextCols,
|
||||
shouldClaim,
|
||||
});
|
||||
};
|
||||
this.fitAndEmitResize = fitAndEmitResize;
|
||||
|
||||
fitAndEmitResize(true);
|
||||
fitAndEmitResize({ force: true, shouldClaim: false });
|
||||
|
||||
const inputDisposable = terminal.onData((data) => {
|
||||
if (this.suppressInput) {
|
||||
@@ -454,12 +471,12 @@ export class TerminalEmulatorRuntime {
|
||||
terminal,
|
||||
});
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAndEmitResize(false);
|
||||
fitAndEmitResize({ shouldClaim: true });
|
||||
});
|
||||
resizeObserver.observe(input.root);
|
||||
resizeObserver.observe(input.host);
|
||||
|
||||
const windowResizeHandler = () => fitAndEmitResize(false);
|
||||
const windowResizeHandler = () => fitAndEmitResize({ shouldClaim: true });
|
||||
window.addEventListener("resize", windowResizeHandler);
|
||||
const windowFocusHandler = () => {
|
||||
this.handleVisibilityRestore();
|
||||
@@ -472,26 +489,23 @@ export class TerminalEmulatorRuntime {
|
||||
document.addEventListener("visibilitychange", documentVisibilityChangeHandler);
|
||||
|
||||
const visualViewport = window.visualViewport;
|
||||
const visualViewportResizeHandler = () => fitAndEmitResize(false);
|
||||
const visualViewportResizeHandler = () => fitAndEmitResize({ shouldClaim: true });
|
||||
visualViewport?.addEventListener("resize", visualViewportResizeHandler);
|
||||
|
||||
const fitInterval = window.setInterval(() => {
|
||||
fitAndEmitResize(false);
|
||||
}, 250);
|
||||
const fitTimeouts = FIT_TIMEOUT_DELAYS_MS.map((delayMs) =>
|
||||
window.setTimeout(() => {
|
||||
fitAndEmitResize(true);
|
||||
fitAndEmitResize({ force: true, shouldClaim: false });
|
||||
}, delayMs),
|
||||
);
|
||||
|
||||
const fontSet = document.fonts;
|
||||
const fontReadyHandler = () => {
|
||||
fitAndEmitResize(true);
|
||||
fitAndEmitResize({ force: true, shouldClaim: false });
|
||||
};
|
||||
fontSet?.addEventListener?.("loadingdone", fontReadyHandler);
|
||||
void fontSet?.ready
|
||||
.then(() => {
|
||||
fitAndEmitResize(true);
|
||||
fitAndEmitResize({ force: true, shouldClaim: false });
|
||||
return;
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -499,7 +513,7 @@ export class TerminalEmulatorRuntime {
|
||||
});
|
||||
|
||||
window.setTimeout(() => {
|
||||
fitAndEmitResize(true);
|
||||
fitAndEmitResize({ force: true, shouldClaim: false });
|
||||
}, 0);
|
||||
|
||||
if (input.initialSnapshot) {
|
||||
@@ -527,9 +541,6 @@ export class TerminalEmulatorRuntime {
|
||||
removeVisualViewportResize: () => {
|
||||
visualViewport?.removeEventListener("resize", visualViewportResizeHandler);
|
||||
},
|
||||
clearFitInterval: () => {
|
||||
window.clearInterval(fitInterval);
|
||||
},
|
||||
clearFitTimeouts: () => {
|
||||
for (const handle of fitTimeouts) {
|
||||
window.clearTimeout(handle);
|
||||
@@ -565,7 +576,6 @@ export class TerminalEmulatorRuntime {
|
||||
disposables.removeWindowFocus();
|
||||
disposables.removeDocumentVisibilityChange();
|
||||
disposables.removeVisualViewportResize();
|
||||
disposables.clearFitInterval();
|
||||
disposables.clearFitTimeouts();
|
||||
disposables.removeFontListeners();
|
||||
disposables.removeTouchListeners();
|
||||
@@ -635,8 +645,8 @@ export class TerminalEmulatorRuntime {
|
||||
this.processOutputQueue();
|
||||
}
|
||||
|
||||
resize(input?: { force?: boolean }): void {
|
||||
this.fitAndEmitResize?.(input?.force ?? false);
|
||||
resize(input?: { force?: boolean; shouldClaim?: boolean }): void {
|
||||
this.fitAndEmitResize?.(input);
|
||||
}
|
||||
|
||||
setTheme(input: { theme: ITheme }): void {
|
||||
@@ -672,6 +682,24 @@ 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) {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
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,6 +18,8 @@ interface MountMessage {
|
||||
initialSnapshot: TerminalState | null;
|
||||
scrollbackLines: number;
|
||||
theme: ITheme;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
pendingModifiers: PendingTerminalModifiers;
|
||||
swipeGesturesEnabled: boolean;
|
||||
}
|
||||
@@ -30,9 +32,10 @@ type InboundMessage =
|
||||
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
|
||||
| { type: "clear"; streamKey: string }
|
||||
| { type: "focus"; streamKey: string; forceRefocus?: boolean }
|
||||
| { type: "resize"; streamKey: string }
|
||||
| { type: "resize"; streamKey: string; shouldClaim?: boolean }
|
||||
| { 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 }
|
||||
| {
|
||||
@@ -46,7 +49,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 }
|
||||
| { type: "resize"; streamKey: string; rows: number; cols: number; shouldClaim?: boolean }
|
||||
| {
|
||||
type: "terminalKey";
|
||||
streamKey: string;
|
||||
@@ -227,6 +230,8 @@ 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) });
|
||||
@@ -244,21 +249,36 @@ class TerminalWebViewBridge {
|
||||
this.runtime?.focus({ forceRefocus: message.forceRefocus });
|
||||
break;
|
||||
case "resize":
|
||||
this.runtime?.resize({ force: true });
|
||||
this.runtime?.resize({ force: true, shouldClaim: message.shouldClaim !== false });
|
||||
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 });
|
||||
break;
|
||||
return true;
|
||||
case "setScrollback":
|
||||
this.runtime?.setScrollback({ lines: message.lines });
|
||||
break;
|
||||
return true;
|
||||
case "setFont":
|
||||
this.runtime?.setFont({ fontFamily: message.fontFamily, fontSize: message.fontSize });
|
||||
return true;
|
||||
case "setPendingModifiers":
|
||||
this.runtime?.setPendingModifiers({ pendingModifiers: message.pendingModifiers });
|
||||
break;
|
||||
return true;
|
||||
case "setSwipeGesturesEnabled":
|
||||
this.swipeGesturesEnabled = message.enabled;
|
||||
break;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,8 +293,8 @@ class TerminalWebViewBridge {
|
||||
runtime.setCallbacks({
|
||||
callbacks: {
|
||||
onInput: (data) => sendToNative({ type: "input", streamKey: message.streamKey, data }),
|
||||
onResize: ({ rows, cols }) =>
|
||||
sendToNative({ type: "resize", streamKey: message.streamKey, rows, cols }),
|
||||
onResize: ({ rows, cols, shouldClaim }) =>
|
||||
sendToNative({ type: "resize", streamKey: message.streamKey, rows, cols, shouldClaim }),
|
||||
onTerminalKey: (input) =>
|
||||
sendToNative({ type: "terminalKey", streamKey: message.streamKey, ...input }),
|
||||
onPendingModifiersConsumed: () =>
|
||||
@@ -300,6 +320,8 @@ 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 });
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user