Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 2b4d14672c fix(server): deduplicate concurrent agent creation
Concurrent create requests for one client message each ran the creation lifecycle. Share the in-flight operation and reply to every requester with the resulting agent so retries cannot start duplicate provider sessions.
2026-08-12 09:09:52 +00:00
269 changed files with 4172 additions and 8940 deletions
-50
View File
@@ -1,55 +1,5 @@
# Changelog
## 0.4.0 - 2026-08-13
### Breaking
- Removed `paseo chat` and `paseo loop` ([#3053](https://github.com/getpaseo/paseo/pull/3053))
### Added
- Added host-wide agent profiles for reusable provider, model, mode, thinking, and feature settings ([#3208](https://github.com/getpaseo/paseo/pull/3208))
- Added workspace file search to Cmd/Ctrl+P ([#3059](https://github.com/getpaseo/paseo/pull/3059))
- Added file and folder actions to Files and Changes ([#3027](https://github.com/getpaseo/paseo/pull/3027) by [@nikuscs](https://github.com/nikuscs))
- Added interactive Mermaid diagrams to chats and Markdown previews ([#2306](https://github.com/getpaseo/paseo/pull/2306) by [@dmeledon](https://github.com/dmeledon))
- Added live task progress above the composer and in the timeline ([#3227](https://github.com/getpaseo/paseo/pull/3227))
- Added a model setting for generated workspace titles, branch names, commits, and pull request drafts ([#3215](https://github.com/getpaseo/paseo/pull/3215))
- Added the supported `@getpaseo/client` TypeScript SDK ([#3141](https://github.com/getpaseo/paseo/pull/3141))
- Added provider installation diagnostics to the CLI ([#3243](https://github.com/getpaseo/paseo/pull/3243))
- Added workspace rename to the CLI ([#3209](https://github.com/getpaseo/paseo/pull/3209) by [@martinhanzik](https://github.com/martinhanzik))
### Improved
- Added a centered reading layout and formatted YAML front matter to Markdown previews ([#3240](https://github.com/getpaseo/paseo/pull/3240))
- Added sidebar grouping to the Command Center ([#3063](https://github.com/getpaseo/paseo/pull/3063) by [@cleiter](https://github.com/cleiter))
- Added every built-in theme to theme shortcuts ([#3214](https://github.com/getpaseo/paseo/pull/3214))
- Removed periodic Git polling for idle workspaces ([#3323](https://github.com/getpaseo/paseo/pull/3323))
- Set provider catalog refreshes to a configurable two-minute deadline ([#3322](https://github.com/getpaseo/paseo/pull/3322))
### Fixed
- Fixed terminal sessions being lost after host sleep or daemon worker stalls ([#3235](https://github.com/getpaseo/paseo/pull/3235), [#3263](https://github.com/getpaseo/paseo/pull/3263))
- Fixed daemon hang when archiving a workspace ([#3107](https://github.com/getpaseo/paseo/pull/3107))
- Fixed New Workspace crash on Android when projects span multiple hosts ([#3241](https://github.com/getpaseo/paseo/pull/3241))
- Fixed provider cache exhausting Android local storage ([#3234](https://github.com/getpaseo/paseo/pull/3234))
- Fixed dictation recordings being lost during connection interruptions ([#3159](https://github.com/getpaseo/paseo/pull/3159))
- Fixed new workspaces reusing an existing worktree ([#3224](https://github.com/getpaseo/paseo/pull/3224))
- Fixed new worktrees ignoring the setup command saved in Project Settings ([#3233](https://github.com/getpaseo/paseo/pull/3233))
- Fixed new worktrees becoming dirty when the selected base contained a different `paseo.json` ([#3311](https://github.com/getpaseo/paseo/pull/3311))
- Fixed removed hosts continuing to receive push notifications ([#3176](https://github.com/getpaseo/paseo/pull/3176))
- Fixed delegated-agent notifications stopping after permission prompts or workspace closure ([#3177](https://github.com/getpaseo/paseo/pull/3177), [#3192](https://github.com/getpaseo/paseo/pull/3192) by [@thomasvan](https://github.com/thomasvan), [@wilgon456](https://github.com/wilgon456))
- Fixed OMP agent startup when model catalogs exceed the protocol-v1 frame limit ([#3184](https://github.com/getpaseo/paseo/pull/3184) by [@pi3123](https://github.com/pi3123))
- Fixed OpenCode models being unable to return to their default variant ([#3281](https://github.com/getpaseo/paseo/pull/3281))
- Fixed completed Codex subagents remaining marked active ([#3188](https://github.com/getpaseo/paseo/pull/3188) by [@Strainy](https://github.com/Strainy))
- Fixed Codex compaction leaving turns marked as working ([#3211](https://github.com/getpaseo/paseo/pull/3211) by [@edihasaj](https://github.com/edihasaj))
- Fixed Cursor usage with current Cursor logins ([#2704](https://github.com/getpaseo/paseo/pull/2704) by [@QuteSaltyFish](https://github.com/QuteSaltyFish))
- Fixed SVG project icons rendering as blank on mobile ([#2579](https://github.com/getpaseo/paseo/pull/2579) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed crash when persisted cache was incompatible ([#3289](https://github.com/getpaseo/paseo/pull/3289))
- Fixed open delegated agents being archived with their parent ([#3279](https://github.com/getpaseo/paseo/pull/3279))
- Fixed agent creation when profiles carried modes unsupported by the selected provider ([#3331](https://github.com/getpaseo/paseo/pull/3331))
- Fixed Claude agents waiting on Paseo's ten-minute MCP timeout ([#3315](https://github.com/getpaseo/paseo/pull/3315))
- Fixed Copy resume command for Hermes agents ([#3300](https://github.com/getpaseo/paseo/pull/3300) by [@desmond-rai](https://github.com/desmond-rai))
## 0.3.1 - 2026-08-09
### Added
+10 -23
View File
@@ -44,21 +44,9 @@ Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Age
- **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- **Detached agents** — stand on their own after an explicit detach transition, do not appear in the former parent's subagent track, and are not archived with it.
Parent archive detaches a subagent instead of archiving it when either condition holds:
- The child belongs to another workspace.
- The child is currently open in an agent tab.
All other children archive with the parent. After the workspace layout hydrates, the client marks
every managed subagent present in its tabs with `paseo.open-agent-tab.<client-id>=true` through the
generic agent metadata update. This includes background and restored tabs; navigation does not own
the marker. Closing a tab sets that client's label to `false`. Any `true` client label keeps the child
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
normal root agent immediately, and closing its still-open tab archives it.
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation only selects an existing workspace.
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
@@ -86,7 +74,7 @@ Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/
2. Set `archivedAt` and normalize `lastStatus` away from `running`/`initializing`
3. Notify subscribers
4. Close the runtime (kills the process if still running)
5. **Resolve children** — detach cross-workspace and open-tab children; cascade-archive the rest recursively
5. **Cascade-archive children** — any agent whose `paseo.parent-agent-id` label matches the archived agent gets archived too, recursively
Cascade is what keeps subagent fleets from outliving their orchestrator.
@@ -121,7 +109,7 @@ These are two distinct concepts that used to be conflated:
Closing a tab on a **root agent** still archives — the tab is the agent's home, so closing it means "I'm done with this agent." A confirm dialog protects against archiving a running agent by accident.
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The app clears the current client's open-tab label before removing the tab. Another client's open tab remains protected. The agent stays unarchived and stays in its parent's track, so a later parent archive cascades to it when no client still has it open. The user can re-open the tab from the track at any time. Single and bulk tab close apply the same policy.
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The agent stays unarchived and stays in its parent's track. The user can re-open the tab from the track at any time. This is implemented in `handleCloseAgentTab` (`packages/app/src/screens/workspace/workspace-screen.tsx`).
The asymmetry is intentional: a subagent's persistent relationship lives in the parent's track. Same-workspace subagents are not auto-opened as tabs; the user opens one from that track when needed. A cross-workspace subagent is also auto-opened as a tab in its own workspace so opening that workspace does not appear empty. It remains in the parent's track until it is actually detached.
@@ -167,7 +155,7 @@ Archived Paseo subagents disappear from the track, by design. To remove one from
The track header's **Archive finished** action hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track.
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the relationship lifecycle labels, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
## Why this shape
@@ -201,12 +189,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 for agent-scoped creation and removed by detach |
| `labels["paseo.open-agent-tab.<client-id>"]` | `string?` | `"true"` protects an open tab on that client; detach clears every matching label |
| `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 for agent-scoped creation and removed by detach |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
See [`docs/data-model.md`](./data-model.md) for the full agent record.
+1 -15
View File
@@ -2,7 +2,7 @@
Paseo supports configuring custom agent providers through `config.json` (located at `$PASEO_HOME/config.json`, typically `~/.paseo/config.json`). You can extend built-in providers with different API backends, add ACP-compatible agents, set custom binaries, disable providers, and create multiple profiles for the same underlying provider.
Provider definitions live under `agents.providers` in config.json:
All provider configuration lives under `agents.providers` in config.json:
```json
{
@@ -17,20 +17,6 @@ Provider definitions live under `agents.providers` in config.json:
Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`).
Each provider catalog refresh waits up to 2 minutes. If a provider loads many plugins or a large
agent catalog during startup, raise the limit in milliseconds:
```json
{
"agents": {
"catalogRefreshTimeoutMs": 180000
}
}
```
The limit applies independently to every provider refresh and covers availability plus the entire
catalog probe. `PASEO_PROVIDER_REFRESH_TIMEOUT_MS` sets it when the config field is absent.
---
## Table of Contents
+11 -32
View File
@@ -30,20 +30,7 @@ Workspace archive runs lifecycle teardown from the exact `cwd` but removes only
`worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing
checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`.
Paseo stores schedules and push tokens in `$PASEO_HOME/paseo.db`. The remaining record stores use
JSON files. SQLite migrations are ordered SQL scripts tracked by `PRAGMA user_version`. Record
tables keep identity and queried fields in columns and the complete record in a JSON `payload`
column. Reads validate that payload with the same Zod schema used at the store boundary.
Each SQLite-backed store owns a one-way import from its legacy JSON path. On first open, an empty
table imports valid legacy records in one transaction and writes a marker to `legacy_imports`.
Invalid records are skipped and logged. Once the marker exists, SQLite is the only authority for
that store: the daemon does not read from or write to the legacy path. Legacy files remain untouched
so downgrading resumes from the state captured when the import ran; there is no automatic
down-migration.
JSON stores rely on optional fields with defaults for forward compatibility. Most write atomically
with a temporary file and rename; see each section for exceptions.
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries.
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
@@ -62,12 +49,11 @@ $PASEO_HOME/
├── daemon-keypair.json # E2EE keypair for relay (mode 0600)
├── paseo.pid # Daemon PID lock file
├── daemon.log # Default log file (path configurable)
├── paseo.db # SQLite schedules, push tokens, and import markers
├── agents/
│ └── {sanitized-cwd}/
│ └── {agentId}.json # One file per agent
├── schedules/ # Legacy schedule import source, left untouched
│ └── {scheduleId}.json
├── schedules/
│ └── {scheduleId}.json # One file per schedule
├── projects/
│ ├── projects.json # Project registry
│ ├── workspaces.json # Workspace registry
@@ -75,10 +61,10 @@ $PASEO_HOME/
├── runtime/
│ └── managed-processes/
│ └── {recordId}.json # Helper processes owned by Paseo; reconciled on daemon bootstrap
└── push-tokens.json # Legacy push token import source, left untouched
└── push-tokens.json # Expo push notification tokens
```
The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` by stripping the filesystem root and replacing path separators with `-` (Windows drive letters become a `C-` style prefix). File-backed record stores write atomically by writing a temp file in the target directory and then renaming it into place.
The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` by stripping the filesystem root and replacing path separators with `-` (Windows drive letters become a `C-` style prefix). Persistent server stores write atomically by writing a temp file in the target directory and then renaming it into place.
---
@@ -99,7 +85,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). Paseo uses `paseo.parent-agent-id` for parentage and client-scoped `paseo.open-agent-tab.*` labels while managed subagent tabs are open — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for agent-scoped creation and removed by detach — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
@@ -339,11 +325,9 @@ Paseo uses these paths under the configured OpenAI base URL:
## 3. Schedule
**Table:** `paseo.db:schedules`
**Path:** `$PASEO_HOME/schedules/{id}.json`
Each row stores `id`, `created_at`, the active name-and-target `identity_key`, and the validated
schedule JSON in `payload`. ID is 8 hex characters. The legacy
`$PASEO_HOME/schedules/{id}.json` files are import sources only.
One file per schedule. ID is 8 hex characters.
| Field | Type | Description |
| ----------- | ------------------------------------- | -------------------------------- |
@@ -458,20 +442,15 @@ than treating it as valid.
## 6. Push Token Store
**Table:** `paseo.db:push_tokens`
Legacy import source:
**Path:** `$PASEO_HOME/push-tokens.json`
```json
{
"subscriptions": [{ "token": "ExponentPushToken[...]", "expiresAt": "2026-08-12T00:00:00.000Z" }]
"tokens": ["ExponentPushToken[...]", ...]
}
```
The older `{ "tokens": [...] }` shape is imported with a fresh lease. Each token is a row keyed by
its normalized string, with the validated subscription record in `payload`.
`$PASEO_HOME/push-tokens.json` is read once as an import source. Non-string and empty legacy entries
are skipped and logged.
Simple set of Expo push notification tokens. Loaded with permissive parsing (filters non-string entries). Persisted with atomic temp-file rename.
---
+6 -6
View File
@@ -131,11 +131,9 @@ desktop-only environment inherited by terminals opened inside Paseo from couplin
a new worktree instance to the parent desktop instance's profile or single-instance
lock.
The desktop workspace script `exec`s the dev runner so the terminal owns the runner
PID. Terminal shutdown reaches the runner as `SIGHUP`; the runner stops Metro and
asks Electron to quit through its normal app lifecycle. Do not add an npm wrapper or
detach Electron: either change leaves an orphan holding the worktree's single-instance
lock and broken output pipes.
Electron remains in the desktop dev runner's process group. Closing or stopping the
workspace-script terminal must terminate Electron with Metro; detaching Electron
leaves an orphan holding the worktree's single-instance lock and broken output pipes.
With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen
transition, and 751-pixel settings split with:
@@ -297,7 +295,9 @@ another remote fails closed until the worktree records an explicit local target.
the optional field and retain the previous local-first behavior; older worktree metadata without the
exact ref also resolves through its stored branch name.
Worktrees inherit committed Git state only; uncommitted source-checkout changes are not copied.
Worktrees inherit committed Git state. Before lifecycle setup, Paseo copies the source checkout's
`paseo.json` over the worktree copy so saved Project Settings apply without a commit. Other
uncommitted source-checkout changes are not copied.
## paseo.json service scripts
-2
View File
@@ -16,6 +16,4 @@ Read aggregate health from the owning observer service. Runtime metrics include
Git owns Git-ignore evaluation. The observer accepts absolute excluded roots and applies updates without replacing the observation or exposing its watcher topology. This keeps tracked files inside otherwise ignored directories observable and keeps Git policy out of the filesystem module.
Workspace Git verifies each repository metadata subscription with a one-shot canary inside the Git directory. If the event does not round-trip through the subscription callback, treat the watcher as unavailable and enter degraded polling. Refresh working-tree Git-ignore exclusions from ignore-file events and watcher recovery, never from a healthy-watcher timer.
The real-filesystem contracts and daemon auto-archive lifecycle run in the normal server test suite. Use the scripts only for manual performance and soak work: `npm run measure:file-observer --workspace=@getpaseo/server` measures burst and sustained-create behavior, and `npm run repro:file-observer-teardown --workspace=@getpaseo/server` runs the teardown soak.
+5 -5
View File
@@ -95,11 +95,11 @@ Hub authentication rejection or close code `4403` permanently revokes the local
daemon deletes its credential, stops reconnecting, and retains only the relationship ID, Hub origin,
scopes, and a sanitized reason for status reporting.
`paseo hub disconnect` disables socket reconnect and execution authority before making one bounded
remote revocation request. The daemon then removes the local relationship whether the request
succeeds or fails. A failed request returns a warning that server-side revocation may remain pending.
`--force` skips the remote request. Legacy persisted `disconnecting` records are removed on startup;
the daemon does not retry revocation in the background.
`paseo hub disconnect` disables socket reconnect before requesting remote revocation. If the Hub is
offline, the daemon persists `disconnecting` and retries revocation across daemon restarts without
opening a Hub socket. This also covers an enrollment whose request may have succeeded but whose
response was lost. `--force` removes local authority immediately and warns that remote revocation may
still be pending.
`paseo hub logout` removes only the active human CLI credential and preserves credentials for other origins. Interactive logout inspects and optionally disconnects a same-origin daemon before deleting the login; a failed requested disconnect preserves the login. JSON and noninteractive logout never prompt or disconnect implicitly.
-7
View File
@@ -105,13 +105,6 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star
The daemon keeps provider snapshots per resolved working directory, with a separate semantic global scope for settings/provider management and requests that do not carry a cwd. Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` for project-scoped refreshes. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory.
`ProviderSnapshotManager` owns one refresh deadline per provider. The deadline starts before the
availability check and covers that check plus the complete catalog probe. Providers that make
multiple catalog requests must not apply this deadline separately to each request. The manager
aborts the shared refresh signal at the deadline. Providers name active catalog operations and
finish subprocess, server, or session cleanup before rejecting. Timeout errors list the operations
that were still active when the deadline expired.
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
Capable clients receive a compact, content-addressed snapshot. Model rows derive their provider from the containing entry and reference snapshot-level thinking sets. The app persists that compact shape per server and cwd, then sends its hash on the next pull; an unchanged response carries no catalog body. Keep the legacy encoding for clients without the capability. The hash covers the complete client-visible compact snapshot, including status and `fetchedAt`, so explicit refreshes invalidate it even when the discovered catalog is otherwise equal.
+16 -28
View File
@@ -464,37 +464,26 @@ No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or
## Changelog ownership
- **The agent running the release writes the changelog entry — beta or stable.** The release context and final wording stay with that agent.
- **Commit history is only an index of the changes. Never draft the changelog from commit subjects or diffs alone.** For every PR in the release range, read the full PR description and every issue it links to before deciding what changed, why users care, or how changes should be grouped. Use the implementation only to verify the resulting understanding.
- For the first beta or a direct stable release, draft from the previous stable tag to the release source. For later betas, draft from the previous beta tag to the release source. Promotion replaces the beta series with one entry drafted from the previous stable tag to the release source. Review the result against the changelog policy below, show it to the user, and wait for approval before committing it.
## Changelog wording
## Changelog voice
The changelog is shown on the Paseo homepage. Each bullet is a compact factual record of
product behavior that changed.
The changelog is shown on the Paseo homepage. Write it for **end users**, not developers.
- **Name the exact change.** Prefer `Added <capability>`, `Removed <behavior>`,
`Changed <behavior>`, or `Fixed <failure> when <condition>`.
- **Keep the scope exact.** A conditional bug is not a general reliability problem. Do not
broaden one failure into claims that Paseo is now faster, smoother, responsive, or reliable.
- **Use concrete product and runtime terms.** Git polling, persisted cache, provider catalog,
and WebSocket reconnects can identify the affected behavior. Component names, internal
modules, code symbols, and implementation techniques cannot: omit `WorkingIndicator`,
`reconcileAndEmitWorkspaceUpdates`, remounts, memoization, and controlled inputs.
- **State the consequence only when the change itself is unclear.** Keep the condition that
makes the consequence true. Do not replace a precise change with a broad benefit claim.
- **Do not invent context.** Mention an upgrade, platform, workload, or user action only when
the PR or linked issue establishes that scope.
- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts.
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`. Also no "virtualized lists", no "remount", no "memoization", no "debounced", no "fuzzy ranking", no "controlled input", no "uncontrolled input" — these are implementation words masquerading as user-facing copy.
- **Concrete WRONG → RIGHT examples** (real mistakes from past releases):
| Avoid | Write |
| ------------------------------------------------------------ | ----------------------------------------------------- |
| Paseo stays responsive with many idle Git workspaces | Removed periodic Git polling for idle workspaces |
| Incompatible saved app data no longer crashes after upgrades | Fixed crash when persisted cache was incompatible |
| Splitting layouts no longer remounts the active agent | Fixed scroll position resetting when splitting a pane |
| Mobile model selector is faster and more straightforward | Added search to the mobile model selector |
| Wrong (implementation-facing) | Right (user-facing) |
| ----------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| Switching layouts no longer remounts the active agent | Splitting a pane no longer loses your scroll position |
| Model, mode, and thinking pickers — searchable virtualized lists with fuzzy ranking | Mobile model selector is faster and more straightforward |
| Text inputs in mobile sheets no longer flicker while typing fast | Typing in mobile sheets no longer flickers |
| Compact web sheets no longer crash when swiped to dismiss | Sheets on mobile web no longer crash when swiped to dismiss |
| Reduced re-renders in the agent list | Agent list scrolls smoothly |
| Added debouncing to the search input | Search results no longer lag behind typing |
Test each bullet against the source PR and issue: can a reviewer point to the exact behavior
that changed, the failure that was fixed, or the capability that was added? If the bullet only
claims a general improvement, rewrite it with the concrete change.
Test: would a non-developer reader recognise what changed when using the app? If they'd need an engineer to translate ("what's a remount?"), the bullet is still implementation-facing — rewrite it as the symptom the user experiences.
- **Use the entry's release scope.** Include changes within the matching range in **Changelog scope**.
- **Collapse internal iterations within that scope.** Present a feature added and fixed in one range as working. A later beta can describe a fix to behavior delivered in an earlier beta; promotion folds the complete beta series into the final stable behavior.
@@ -509,7 +498,8 @@ Every bullet must be scannable at a glance. The changelog is not release documen
- **One line per bullet.** If a bullet wraps to three lines in a narrow column, it's too long.
- **Split bullets that pack multiple distinct changes.** If a bullet uses "and", "plus", a comma list, or an em-dash to chain several independent improvements, break them into separate bullets — even when they share a theme or author. One bullet = one user-facing change.
- **Trim qualifying clauses.** Drop "with a hint shown when…", "matching the CLI's behaviour", "across common install shapes". If the detail doesn't change whether a user cares, cut it.
- **Stop after identifying the change.** Do not explain LAN/WAN topology, TLS handshakes, IPC, or other architecture in a changelog bullet. Put necessary background in product docs.
- **Lead with what the user can do, not the mechanism.** The reader cares about the capability, not how it works under the hood. Do not explain LAN vs WAN, TLS handshakes, IPC, the daemon-relay topology, or any internal concept the user has not asked about. "Self-hosted relays can use a different TLS setting for the public endpoint" — not "Self-hosted relays support a separate TLS setting for the public endpoint, so the daemon can reach the relay over the LAN while the phone reaches it over the public secure address." If a feature genuinely needs background to be understood, it belongs in product docs, with a one-line teaser in the changelog.
- **Lead with the outcome.** "Windows: agents launch reliably from npm `.cmd` shims…" is better than "Windows: agents launch reliably across common install shapes. Claude, Codex, and OpenCode now start correctly…".
- **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets.
## Changelog attribution
@@ -577,7 +567,6 @@ Each beta entry records what its testers receive. Promotion produces the single
### Beta release
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Add a new `CHANGELOG.md` entry for this beta (heading `## X.Y.Z-beta.N - YYYY-MM-DD`), review it against the changelog policy, get approval, and commit it before cutting the release
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
- [ ] Release preparation stayed local until the approved release command pushed the complete branch and tag
@@ -598,7 +587,6 @@ Each beta entry records what its testers receive. Promotion produces the single
- [ ] Run the pre-release sanity check (see above) and address any findings
- [ ] The diff from the previous stable to the resolved release source is classified as patch or minor, with the target version and rationale approved
- [ ] The resolved release source is the intended commit (default `origin/main`) and its existing CI is green
- [ ] Every PR in the release range has been opened, and its full description and every linked issue have been read before drafting the changelog
- [ ] Ensure the approved release inputs are committed locally and the git worktree is clean before running any release command
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any release command
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors). Promotion replaces every `## X.Y.Z-beta.N` entry in the series with one `## X.Y.Z - YYYY-MM-DD` entry covering the full release
+1 -1
View File
@@ -1 +1 @@
sha256-BeS2/PYAPoG4U3q7N2qOVuPC1T5sDQ+c9hv2cTn3KRU=
sha256-L73o8u6OsY3SsA5t2s5mWsMsMNc3qr5bCE4cd1NpbgI=
+22 -38
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.4.0",
"version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.4.0",
"version": "0.3.1",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -36091,7 +36091,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.4.0",
"version": "0.3.1",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37139,12 +37139,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.4.0",
"version": "0.3.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.4.0",
"@getpaseo/protocol": "0.4.0",
"@getpaseo/server": "0.4.0",
"@getpaseo/client": "0.3.1",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/server": "0.3.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37163,9 +37163,6 @@
"typescript": "^5.2.2",
"vitest": "^4.1.6",
"zx": "^8.8.5"
},
"engines": {
"node": ">=22.20.0"
}
},
"packages/cli/node_modules/@vitest/expect": {
@@ -37394,10 +37391,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.4.0",
"version": "0.3.1",
"dependencies": {
"@getpaseo/protocol": "0.4.0",
"@getpaseo/relay": "0.4.0",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/relay": "0.3.1",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37408,7 +37405,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.4.0",
"version": "0.3.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37651,7 +37648,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.4.0",
"version": "0.3.1",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38547,7 +38544,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.4.0",
"version": "0.3.1",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38576,7 +38573,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.4.0",
"version": "0.3.1",
"dependencies": {
"zod": "^4.4.3"
},
@@ -38589,7 +38586,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.4.0",
"version": "0.3.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38807,15 +38804,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.4.0",
"version": "0.3.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.4.0",
"@getpaseo/highlight": "0.4.0",
"@getpaseo/protocol": "0.4.0",
"@getpaseo/relay": "0.4.0",
"@getpaseo/client": "0.3.1",
"@getpaseo/highlight": "0.3.1",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/relay": "0.3.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38848,7 +38845,7 @@
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/express": "^4.17.20",
"@types/node": "^22.20.1",
"@types/node": "^20.9.0",
"@types/qrcode": "^1.5.6",
"@types/uuid": "^9.0.7",
"@types/ws": "^8.5.8",
@@ -38857,9 +38854,6 @@
"tsx": "^4.6.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
},
"engines": {
"node": ">=22.20.0"
}
},
"packages/server/node_modules/@anthropic-ai/claude-agent-sdk": {
@@ -39008,16 +39002,6 @@
"cross-spawn": "7.0.6"
}
},
"packages/server/node_modules/@types/node": {
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"packages/server/node_modules/@vitest/expect": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
@@ -39376,7 +39360,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.4.0",
"version": "0.3.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.4.0",
"version": "0.3.1",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -1,225 +0,0 @@
import { expect, test, type Page } from "../support/fixtures";
import path from "node:path";
import type { FormPreferences } from "@/create-agent-preferences/preferences";
import {
applyProfileFromPicker,
drillIntoProvider,
openModelPicker,
seedAgentProfiles,
seedModelProvider,
} from "../support/helpers/agent-profiles";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import {
openGlobalNewWorkspaceComposer,
selectNewWorkspaceProject,
submitNewWorkspacePrompt,
} from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
const MODELESS_PROVIDER = "modeless-profile-e2e";
const MODELESS_MODEL = "pi-profile-model";
type WebSocketMessage = string | Buffer;
interface CreateAgentRequestMessage {
type: "create_agent_request";
config?: {
provider?: unknown;
modeId?: unknown;
featureValues?: unknown;
};
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(rawMessage) as { type?: unknown; message?: unknown };
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
? (envelope.message as Record<string, unknown>)
: null;
} catch {
return null;
}
}
async function recordAndBlockCreateAgentRequest(page: Page): Promise<{
waitForRequest(): Promise<CreateAgentRequestMessage>;
}> {
let resolveRequest: ((message: CreateAgentRequestMessage) => void) | null = null;
const request = new Promise<CreateAgentRequestMessage>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "create_agent_request") {
resolveRequest?.(sessionMessage as unknown as CreateAgentRequestMessage);
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
return { waitForRequest: () => request };
}
async function seedPoisonedModelessPreference(page: Page): Promise<void> {
await page.addInitScript(
({ preferencesKey, provider, model }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({
provider,
providerPreferences: {
[provider]: { model, mode: "full-access" },
mock: { model: "ten-second-stream", mode: "load-test" },
},
} satisfies FormPreferences),
);
},
{
preferencesKey: CREATE_AGENT_PREFERENCES_KEY,
provider: MODELESS_PROVIDER,
model: MODELESS_MODEL,
},
);
}
async function readProviderModePreference(page: Page, provider: string): Promise<unknown> {
return page.evaluate(
({ preferencesKey, providerId }) => {
const raw = localStorage.getItem(preferencesKey);
if (!raw) return null;
const preferences = JSON.parse(raw) as FormPreferences;
return preferences.providerPreferences?.[providerId]?.mode ?? null;
},
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider },
);
}
test.describe("Agent profiles repair modeless provider preferences", () => {
test.describe.configure({ timeout: 240_000 });
test("applying a profile heals a stale mode before returning to Pi", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "profile-modeless-preferences-" });
const profiles = await seedAgentProfiles([
{
id: "agent_profile_modeless_repair",
name: "Approval work",
provider: "mock",
model: "one-minute-stream",
modeId: "approval-test",
},
]);
const provider = await seedModelProvider({
id: MODELESS_PROVIDER,
label: "Pi profile test",
extends: "pi",
command: [process.execPath, path.resolve("e2e/fixtures/fake-pi-rpc.mjs")],
models: [
{
id: MODELESS_MODEL,
label: "Pi profile model",
description: "Modeless profile regression model",
},
],
});
await seedPoisonedModelessPreference(page);
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await openModelPicker(page);
await applyProfileFromPicker(page, "Approval work");
await expect
.poll(() => readProviderModePreference(page, MODELESS_PROVIDER), { timeout: 10_000 })
.toBeNull();
await expect
.poll(() => readProviderModePreference(page, "mock"), { timeout: 10_000 })
.toBe("approval-test");
await openModelPicker(page);
await drillIntoProvider(page, MODELESS_PROVIDER);
await page.getByRole("button", { name: /Pi profile model/ }).click();
await submitNewWorkspacePrompt(page, "Create a Pi agent after repairing preferences.");
const createAgentRequest = await createAgentRecorder.waitForRequest();
expect(createAgentRequest).toMatchObject({
config: { provider: MODELESS_PROVIDER },
});
expect(createAgentRequest.config).not.toHaveProperty("modeId");
} finally {
await workspace.cleanup();
await provider.restore();
await profiles.restore();
}
});
test("cross-provider profile features survive immediate workspace creation", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "profile-feature-transition-" });
const featureProviderId = "profile-feature-e2e";
const featureModelId = "claude-opus-5";
const profiles = await seedAgentProfiles([
{
id: "agent_profile_feature_transition",
name: "Fast profile",
provider: featureProviderId,
model: featureModelId,
modeId: "bypassPermissions",
featureValues: { fast_mode: true },
},
]);
const provider = await seedModelProvider({
id: featureProviderId,
label: "Profile feature test",
models: [
{
id: featureModelId,
label: "Profile feature model",
description: "Cross-provider feature regression model",
},
],
});
const createAgentRecorder = await recordAndBlockCreateAgentRequest(page);
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await selectNewWorkspaceProject(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await openModelPicker(page);
await applyProfileFromPicker(page, "Fast profile");
await submitNewWorkspacePrompt(page, "Create an agent with profile features.");
const createAgentRequest = await createAgentRecorder.waitForRequest();
expect(createAgentRequest).toMatchObject({
config: {
provider: featureProviderId,
featureValues: { fast_mode: true },
},
});
} finally {
await workspace.cleanup();
await provider.restore();
await profiles.restore();
}
});
});
@@ -1,14 +1,17 @@
import { expect, test } from "../support/fixtures";
import { test } from "../support/fixtures";
import {
applyProfileFromPicker,
closeModelPicker,
drillIntoProvider,
expectComposerDoesNotName,
expectAgentProfilesEmptyPrompt,
expectProfileEditTooltip,
expectComposerMode,
expectComposerModel,
expectModelRowSelected,
expectNothingSelectedInPickerRoot,
expectProfileEditIsPencilOnly,
expectProfilePinnedAboveProviders,
expectProfileVisibleForProvider,
openModelPicker,
openAgentProfilesFromEmptyPrompt,
@@ -71,12 +74,9 @@ test.describe("Agent profiles in the model picker", () => {
await expectComposerMode(page, "Load test");
});
await test.step("the sole provider opens directly", async () => {
await test.step("the profile is pinned above the provider list", async () => {
await openModelPicker(page);
await expect(page.getByTestId("model-search-input").first()).toBeVisible();
await expect(page.getByTestId("sheet-header-back")).toHaveCount(0);
await expect(page.locator('[data-testid^="model-provider-"]')).toHaveCount(0);
await expectProfileVisibleForProvider(page, {
await expectProfilePinnedAboveProviders(page, {
name: PROFILE.name,
summary: PROFILE_SUMMARY,
});
@@ -100,9 +100,17 @@ test.describe("Agent profiles in the model picker", () => {
await expectComposerDoesNotName(page, PROFILE.name);
});
await test.step("reopening returns directly to the provider models", async () => {
await test.step("reopening the picker shows no profile marked as selected", async () => {
await openModelPicker(page);
await expect(page.getByTestId("model-search-input").first()).toBeVisible();
await expectProfilePinnedAboveProviders(page, {
name: PROFILE.name,
summary: PROFILE_SUMMARY,
});
await expectNothingSelectedInPickerRoot(page);
});
await test.step("the model it materialized is the one marked selected", async () => {
await drillIntoProvider(page, "mock");
await expectProfileVisibleForProvider(page, {
name: PROFILE.name,
summary: PROFILE_SUMMARY,
@@ -66,7 +66,7 @@ test("does not repeat an assistant block when the current app paginates a publis
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ seededHost: host, preferences: buildCreateAgentPreferences() },
{ seededHost: host, preferences: buildCreateAgentPreferences(serverId) },
);
const history = await holdOlderHistoryPages(page, agent, daemon.port);
+124 -52
View File
@@ -1,7 +1,7 @@
import { execFileSync } from "node:child_process";
import { readFile, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { type Page } from "@playwright/test";
import { type Locator, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
import { test, expect } from "../support/fixtures";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
@@ -63,6 +63,10 @@ async function failNextDiscardRequest(page: Page): Promise<void> {
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
function diffGroups(scope: Page | Locator, fileIndex = 0): Locator {
return scope.getByTestId(new RegExp(`^diff-file-${fileIndex}-group-\\d+$`));
}
const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
interface UseMountedTabSetInput {
@@ -465,12 +469,12 @@ test("Changes switches between inline and full-tab navigation", async ({ page })
await expect(visiblePanel).toBeVisible();
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("zz-deleted.ts");
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(diffGroups(visiblePanel).first()).toBeVisible();
await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
await expect(diffGroups(visiblePanel)).toHaveCount(0);
await visiblePanel.getByTestId("diff-file-0-toggle").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(diffGroups(visiblePanel).first()).toBeVisible();
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
await workingDiffLayoutToggle.click();
@@ -486,14 +490,12 @@ test("Changes switches between inline and full-tab navigation", async ({ page })
);
await page.keyboard.press("Escape");
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
await expect(visiblePanel.getByTestId(/^diff-file-\d+-group-\d+$/)).toHaveCount(0);
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(diffGroups(visiblePanel).first()).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
await expect(diffGroups(page.getByTestId("explorer-content-area"))).toHaveCount(0);
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
@@ -516,13 +518,9 @@ test("Changes switches between inline and full-tab navigation", async ({ page })
await changesTabToggle.click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toBeVisible();
await expect(diffGroups(page.getByTestId("explorer-content-area")).first()).toBeVisible();
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
await expect(
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
).toHaveCount(0);
await expect(diffGroups(page.getByTestId("explorer-content-area"))).toHaveCount(0);
});
test("changes diff switches between flat and tree file lists", async ({ page }) => {
@@ -553,19 +551,8 @@ test("changes diff switches between flat and tree file lists", async ({ page })
"none",
);
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await expect(page.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(1);
await expect(page.getByTestId("diff-folder-src-toggle").locator("svg")).toHaveCount(2);
await expect(page.getByTestId("diff-file-0-toggle").locator("svg")).toHaveCount(1);
const folderToggleBounds = await page.getByTestId("diff-folder-src-toggle").boundingBox();
const folderChevronBounds = await page
.getByTestId("diff-folder-src-toggle")
.locator("svg")
.boundingBox();
expect(folderToggleBounds).not.toBeNull();
expect(folderChevronBounds).not.toBeNull();
expect(folderChevronBounds!.y + folderChevronBounds!.height / 2).toBeCloseTo(
folderToggleBounds!.y + folderToggleBounds!.height / 2,
0,
);
const folderLabelBounds = await page
.getByTestId("diff-folder-src")
.getByText("src", { exact: true })
@@ -576,7 +563,7 @@ test("changes diff switches between flat and tree file lists", async ({ page })
.boundingBox();
expect(folderLabelBounds).not.toBeNull();
expect(fileLabelBounds).not.toBeNull();
expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(16, 0);
expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(14, 0);
const folderToggle = page.getByTestId("diff-folder-src-toggle");
await folderToggle.click();
@@ -596,7 +583,7 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await page.getByRole("button", { name: "Collapse all" }).click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
await page.getByRole("button", { name: "Expand all" }).click();
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
await expect(diffGroups(page).first()).toBeVisible();
await page.getByTestId("diff-folder-src-toggle").click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
@@ -620,6 +607,90 @@ test("changes diff applies code size changes to gutter and code typography", asy
await expectVisibleDiffRowsShareTypography(page);
});
const LARGE_DIFF_WIDE_LINE_INDEX = 900;
async function createWorkspaceWithLargeUnwrappedDiff(): Promise<DirtyWorkspace> {
const workspace = await createWorkspaceWithMountedTabDiff();
const addedLines = Array.from({ length: 1_200 }, (_, index) => {
if (index === 0) return `\t\t\t\tconst tabbed_${index} = "tabbed";`;
if (index === LARGE_DIFF_WIDE_LINE_INDEX) {
return `const wide_${index} = "${"界".repeat(80)}";`;
}
return `const generated_${index} = ${index};`;
}).join("\n");
await writeFile(
path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"),
`${AFTER}${addedLines}\n`,
);
return workspace;
}
async function scrollToWideDiffLine(page: Page): Promise<Locator> {
await page.getByTestId("git-diff-scroll").evaluate((element, lineIndex) => {
element.scrollTop = lineIndex * 18;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
}, LARGE_DIFF_WIDE_LINE_INDEX);
const wideLine = page.getByText(new RegExp(`const wide_${LARGE_DIFF_WIDE_LINE_INDEX}`));
await expect(wideLine).toHaveCount(1);
return wideLine;
}
test("unwrapped virtualized rows use one horizontal file surface", async ({ page }) => {
const workspace = await createWorkspaceWithLargeUnwrappedDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const horizontalSurface = page.getByTestId("diff-horizontal-scroll");
await expect(horizontalSurface).toHaveCount(1);
await expect(page.getByTestId(/^diff-file-0-group-\d+-scroll$/)).toHaveCount(0);
const codeBefore = await page.getByTestId("diff-code-row-0").boundingBox();
const gutterBefore = await page.getByTestId("diff-gutter-row-0").boundingBox();
expect(codeBefore).not.toBeNull();
expect(gutterBefore).not.toBeNull();
await horizontalSurface.evaluate((element, left) => {
element.scrollLeft = left;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
}, 120);
await expect.poll(() => horizontalSurface.evaluate((element) => element.scrollLeft)).toBe(120);
await expect
.poll(async () => (await page.getByTestId("diff-code-row-0").boundingBox())?.x ?? 0)
.toBeLessThanOrEqual(codeBefore!.x - 119);
await expect
.poll(async () => (await page.getByTestId("diff-gutter-row-0").boundingBox())?.x ?? 0)
.toBeCloseTo(gutterBefore!.x, 0);
const initiallyMountedGroups = await readMountedDiffGroupIds(page);
await page.getByTestId("git-diff-scroll").evaluate((element) => {
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
element.dispatchEvent(new Event("scroll", { bubbles: false }));
});
await expect
.poll(async () => {
const mountedGroups = await readMountedDiffGroupIds(page);
return mountedGroups.some((group) => !initiallyMountedGroups.includes(group));
})
.toBe(true);
await expect.poll(() => horizontalSurface.evaluate((element) => element.scrollLeft)).toBe(120);
await expect(page.getByTestId(/^diff-file-0-group-\d+-scroll$/)).toHaveCount(0);
const wideLine = await scrollToWideDiffLine(page);
await horizontalSurface.evaluate((element) => {
element.scrollLeft = element.scrollWidth;
element.dispatchEvent(new Event("scroll", { bubbles: false }));
});
await expect
.poll(async () => {
const surfaceBounds = await horizontalSurface.boundingBox();
const wideTextRight = await wideLine.evaluate((element) => {
const range = document.createRange();
range.selectNodeContents(element);
return range.getBoundingClientRect().right;
});
if (!surfaceBounds) return false;
return wideTextRight <= surfaceBounds.x + surfaceBounds.width + 1;
})
.toBe(true);
});
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
await page.addInitScript(
({ settingsKey, fontSize }) => {
@@ -645,6 +716,12 @@ async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
);
}
async function readMountedDiffGroupIds(page: Page): Promise<(string | null)[]> {
return diffGroups(page).evaluateAll((groups) =>
groups.map((group) => group.getAttribute("data-testid")),
);
}
async function useUnwrappedDiffLines(page: Page): Promise<void> {
await page.addInitScript(
({ preferencesKey }) => {
@@ -693,21 +770,22 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
codeLineHeight: number;
}[];
}> {
return page.locator("body").evaluate(({ ownerDocument }) => {
const root = ownerDocument.querySelector('[data-testid="diff-file-0-body"]');
if (!root) {
return diffGroups(page).evaluateAll((groups) => {
if (groups.length === 0) {
throw new Error("Expanded diff body is not mounted");
}
const readRows = (prefix: string, textPrefix: string) =>
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => {
const testId = row.getAttribute("data-testid") ?? "";
const index = Number(testId.slice(prefix.length));
const rect = row.getBoundingClientRect();
const text = root.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`);
const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0;
return { index, top: rect.top, height: rect.height, lineHeight };
});
groups.flatMap((group) =>
Array.from(group.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => {
const testId = row.getAttribute("data-testid") ?? "";
const index = Number(testId.slice(prefix.length));
const rect = row.getBoundingClientRect();
const text = row.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`);
const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0;
return { index, top: rect.top, height: rect.height, lineHeight };
}),
);
const gutters = new Map(
readRows("diff-gutter-row-", "diff-gutter-text-").map((row) => [row.index, row]),
@@ -811,7 +889,7 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(diffGroups(page).first()).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
timeout: 30_000,
});
@@ -850,15 +928,9 @@ async function returnToWorkspaceChanges(page: Page): Promise<void> {
}
async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
const lastRowIndex = await page.getByTestId("diff-file-0-body").evaluate((root) => {
const rows = Array.from(root.querySelectorAll<HTMLElement>('[data-testid^="diff-code-row-"]'));
if (rows.length === 0) {
throw new Error("No unwrapped code rows are mounted");
}
return Math.max(
...rows.map((row) => Number((row.getAttribute("data-testid") ?? "").slice(14))),
);
});
await page.getByTestId(`diff-code-row-${lastRowIndex}`).scrollIntoViewIfNeeded();
await expect(page.getByTestId(`diff-code-row-${lastRowIndex}`)).toBeVisible();
const groups = diffGroups(page);
await expect.poll(() => groups.count()).toBeGreaterThan(1);
const lastMountedGroup = groups.last();
await lastMountedGroup.scrollIntoViewIfNeeded();
await expect(lastMountedGroup.locator('[data-testid^="diff-code-row-"]').last()).toBeVisible();
}
@@ -1,5 +1,4 @@
import { test } from "../support/fixtures";
import type { FormPreferences } from "@/create-agent-preferences/preferences";
import { closeCommandCenter, openCommandCenter } from "../support/helpers/command-center";
import {
applyCommandCenterAgentControls,
@@ -13,15 +12,17 @@ import {
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
async function seedMockDraftPreferences(page: import("@playwright/test").Page): Promise<void> {
await page.addInitScript(
({ preferencesKey }) => {
({ preferencesKey, serverId }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({
serverId,
provider: "mock",
providerPreferences: {
mock: {
@@ -29,10 +30,10 @@ async function seedMockDraftPreferences(page: import("@playwright/test").Page):
mode: "load-test",
},
},
} satisfies FormPreferences),
}),
);
},
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY },
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId: getServerId() },
);
}
@@ -55,7 +55,7 @@ test("commit history shows dates and shares diff layout preferences", async ({
await layoutToggle.click();
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
await expect(panel.getByTestId(/^diff-file-0-group-\d+$/).first()).toBeVisible();
await page.getByTestId(/^workspace-commit-diff-close-/).click();
await expect(panel).toHaveCount(0);
@@ -37,7 +37,6 @@ import {
import { seedWorkspace } from "../support/helpers/seed-client";
import { hasGithubAuth, createTempGithubRepo } from "../support/helpers/github-fixtures";
import { getServerId } from "../support/helpers/server-id";
import { openFileExplorer } from "../support/helpers/file-explorer";
const MINIMAL_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
@@ -295,28 +294,6 @@ test.describe("Composer attachments", () => {
}
});
test("Escape cancels file creation without interrupting the running agent", async ({ page }) => {
test.setTimeout(120_000);
const agent = await startRunningMockAgent(page, {
prefix: "file-create-escape-",
model: "one-minute-stream",
prompt: "Stay running while a file draft is cancelled.",
});
try {
await openFileExplorer(page);
await page.getByTestId("files-new-file").click();
const nameInput = page.getByTestId("file-explorer-name-input");
await expect(nameInput).toBeVisible();
await nameInput.press("Escape");
await expect(nameInput).toBeHidden();
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
} finally {
await agent.cleanup();
}
});
test("composer is locked while new workspace agent is being created", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
@@ -124,14 +124,6 @@ test("creates, renames, copies, and deletes entries through the file explorer",
await page.getByTestId("files-new-folder").click();
await expect(nameInput).toBeVisible();
const draftRow = nameInput.locator("xpath=..");
const placeholderColor = await nameInput.evaluate(
(input) => getComputedStyle(input, "::placeholder").color,
);
const extraMutedChevronColor = await draftRow
.locator("svg")
.evaluate((icon) => getComputedStyle(icon).stroke);
expect(placeholderColor).toBe(extraMutedChevronColor);
await nameInput.press("Tab");
await expect(nameInput).toBeHidden();
@@ -197,38 +189,18 @@ test("creates, renames, copies, and deletes entries through the file explorer",
const fileRow = entry("created.txt").locator(
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
);
await expect(folderRow.locator("svg")).toHaveCount(1);
await expect(folderRow.locator("svg")).toHaveCount(2);
await expect(fileRow.locator("svg")).toHaveCount(1);
const gitRow = entry(".git").locator(
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
);
const collapsedChevronBounds = await gitRow.locator("svg").first().boundingBox();
const expandedChevronBounds = await folderRow.locator("svg").first().boundingBox();
const collapsedChevronSlotBounds = await gitRow
.locator("svg")
.first()
.locator("xpath=../..")
.boundingBox();
const expandedChevronSlotBounds = await folderRow
.locator("svg")
.first()
.locator("xpath=../..")
.boundingBox();
expect(collapsedChevronBounds).not.toBeNull();
expect(expandedChevronBounds).not.toBeNull();
expect(collapsedChevronSlotBounds).not.toBeNull();
expect(expandedChevronSlotBounds).not.toBeNull();
const collapsedChevronCenter = collapsedChevronBounds!.x + collapsedChevronBounds!.width / 2;
const expandedChevronCenter = expandedChevronBounds!.x + expandedChevronBounds!.width / 2;
expect(collapsedChevronCenter).toBeCloseTo(expandedChevronCenter, 0);
expect(collapsedChevronCenter).toBeCloseTo(
collapsedChevronSlotBounds!.x + collapsedChevronSlotBounds!.width / 2,
0,
);
expect(expandedChevronCenter).toBeCloseTo(
expandedChevronSlotBounds!.x + expandedChevronSlotBounds!.width / 2,
0,
);
const folderLabelBounds = await entry("folder").boundingBox();
const fileLabelBounds = await entry("created.txt").boundingBox();
expect(folderLabelBounds).not.toBeNull();
@@ -1,5 +1,4 @@
import { expect, test, type Page } from "../support/fixtures";
import type { FormPreferences } from "@/create-agent-preferences/preferences";
import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { openAgentRoute } from "../support/helpers/mock-agent";
@@ -11,6 +10,7 @@ import {
import { expectNoTruncation } from "../support/helpers/no-truncation";
import { escapeRegex } from "../support/helpers/regex";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
@@ -49,12 +49,13 @@ function getSessionMessage(message: WebSocketMessage): Record<string, unknown> |
return maybeEnvelope.message as Record<string, unknown>;
}
async function seedCodexDefaultPermissionPreferences(page: Page): Promise<void> {
async function seedCodexDefaultPermissionPreferences(page: Page, serverId: string): Promise<void> {
await page.addInitScript(
({ preferencesKey }) => {
({ preferencesKey, serverId: seededServerId }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({
serverId: seededServerId,
provider: "codex",
providerPreferences: {
codex: {
@@ -68,10 +69,10 @@ async function seedCodexDefaultPermissionPreferences(page: Page): Promise<void>
model: "ten-second-stream",
},
},
} satisfies FormPreferences),
}),
);
},
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY },
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId },
);
}
@@ -154,9 +155,10 @@ test.describe("New workspace Codex mode preferences", () => {
test("keeps Full Access as the global Codex mode after the workspace draft auto-submit handoff", async ({
page,
}) => {
const serverId = getServerId();
const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" });
const createAgentRecorder = await recordAndBlockCreateAgentRequests(page);
await seedCodexDefaultPermissionPreferences(page);
await seedCodexDefaultPermissionPreferences(page, serverId);
try {
await gotoAppShell(page);
@@ -192,8 +194,9 @@ test.describe("New workspace Codex mode preferences", () => {
});
test("uses the live Codex agent mode as the next New Workspace default", async ({ page }) => {
const serverId = getServerId();
const seeded = await seedWorkspace({ repoPrefix: "codex-live-mode-preferences-" });
await seedCodexDefaultPermissionPreferences(page);
await seedCodexDefaultPermissionPreferences(page, serverId);
try {
const agent = await seeded.client.createAgent({
@@ -1,5 +1,4 @@
import { expect, test, type Page } from "../support/fixtures";
import type { FormPreferences } from "@/create-agent-preferences/preferences";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { openAgentRoute } from "../support/helpers/mock-agent";
import {
@@ -7,6 +6,7 @@ import {
selectNewWorkspaceProject,
} from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
@@ -62,12 +62,13 @@ async function recordSetAgentModeRequests(page: Page): Promise<{
};
}
async function seedCodexDefaultPreferences(page: Page): Promise<void> {
async function seedCodexDefaultPreferences(page: Page, serverId: string): Promise<void> {
await page.addInitScript(
({ preferencesKey }) => {
({ preferencesKey, serverId: seededServerId }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({
serverId: seededServerId,
provider: "codex",
providerPreferences: {
codex: {
@@ -77,10 +78,10 @@ async function seedCodexDefaultPreferences(page: Page): Promise<void> {
},
mock: { model: "ten-second-stream" },
},
} satisfies FormPreferences),
}),
);
},
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY },
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId },
);
}
@@ -103,8 +104,9 @@ test.describe("New Workspace mode cycle safety", () => {
// control and silently change that (possibly running) agent's mode — e.g. into a
// permissive/bypass mode. See use-keyboard-action-handler.ts.
test("Shift+Tab in New Workspace never changes a backgrounded agent's mode", async ({ page }) => {
const serverId = getServerId();
const seeded = await seedWorkspace({ repoPrefix: "mode-cycle-safety-" });
await seedCodexDefaultPreferences(page);
await seedCodexDefaultPreferences(page, serverId);
const modeRequests = await recordSetAgentModeRequests(page);
try {
@@ -9,14 +9,12 @@ import {
clickReloadProjectSettings,
clickRetryProjectSettingsSave,
clickSaveProjectSettings,
commitPaseoConfig,
corruptPaseoConfig,
editWorktreeSetup,
expectEmptyScriptList,
expectProjectHostContextHidden,
expectNoEditableTarget,
expectNoProjectSettingsError,
expectNoUncommittedSetupWarning,
expectProjectEditFailed,
expectProjectEditName,
expectProjectEditSaved,
@@ -29,7 +27,6 @@ import {
expectSaveButtonDisabled,
expectScriptRowCount,
expectWriteFailedCalloutActions,
expectUncommittedSetupWarning,
fillProjectIconUrl,
fillProjectName,
installDaemonConnectionGate,
@@ -219,16 +216,9 @@ test.describe("Projects settings", () => {
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
await openProjects(page);
await openProjectSettings(page, editableProject.name);
await expectNoUncommittedSetupWarning(page);
await editWorktreeSetup(page, updatedSetup);
await clickSaveProjectSettings(page);
await expectProjectConfigSaved(editableProject);
await expectUncommittedSetupWarning(page);
commitPaseoConfig(editableProject.path);
await returnToProjectsList(page);
await openProjectSettings(page, editableProject.name);
await expectNoUncommittedSetupWarning(page);
});
test("project navigation stays inside the selected host", async ({ page, editableProject }) => {
@@ -449,7 +439,5 @@ test.describe("Projects settings — error UX", () => {
await expectScriptRowCount(page, 0);
await expectEmptyScriptList(page);
await clickSaveProjectSettings(page);
await expectNoUncommittedSetupWarning(page);
});
});
@@ -31,7 +31,7 @@ test("Sessions shows an empty placeholder when the host has no history", async (
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ seededHost: host, preferences: buildCreateAgentPreferences() },
{ seededHost: host, preferences: buildCreateAgentPreferences(serverId) },
);
await gotoAppShell(page);
@@ -7,7 +7,6 @@ import path from "node:path";
import net from "node:net";
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import type { FormPreferences } from "@/create-agent-preferences/preferences";
import { metroTest as test } from "../support/fixtures";
import { buildSeededHost } from "../support/helpers/daemon-registry";
import { loadDaemonClientConstructor } from "../support/helpers/daemon-client-loader";
@@ -349,14 +348,12 @@ async function seedBrowserForDaemon(page: Page, input: { serverId: string; port:
{
daemon: host,
preferences: {
serverId: input.serverId,
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4-mini",
thinkingByModel: { "gpt-5.4-mini": "low" },
},
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" },
},
} satisfies FormPreferences,
},
},
);
}
@@ -75,7 +75,7 @@ test.describe("Worktree restore after daemon restart", () => {
label: "restart daemon",
nowIso,
}),
preferences: buildCreateAgentPreferences(),
preferences: buildCreateAgentPreferences(serverId),
},
);
}
-38
View File
@@ -1,38 +0,0 @@
import readline from "node:readline";
const lines = readline.createInterface({ input: process.stdin });
for await (const line of lines) {
const command = JSON.parse(line);
let data = {};
if (command.type === "get_available_models") {
data = {
models: [
{
provider: "e2e",
id: "pi-profile-model",
name: "Pi profile model",
},
],
};
} else if (command.type === "get_state") {
data = {
sessionId: "e2e-pi-session",
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
messageCount: 0,
pendingMessageCount: 0,
};
}
process.stdout.write(
`${JSON.stringify({
id: command.id,
type: "response",
command: command.type,
success: true,
data,
})}\n`,
);
}
+1 -1
View File
@@ -132,7 +132,7 @@ const test = metroTest.extend<
endpoint: `127.0.0.1:${daemonPort}`,
nowIso,
});
const createAgentPreferences = buildCreateAgentPreferences();
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
await page.addInitScript(
({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => {
@@ -104,24 +104,21 @@ export interface SeededProviderModel {
* list is what avoids running anything: replacement models skip catalog
* discovery, and Claude's static modes mean the provider never spawns. The
* command only has to resolve for the availability probe, hence `node`.
* Providers with dynamic catalogs can pass a small RPC fixture as `command`.
*/
export async function seedModelProvider(input: {
id: string;
label: string;
models: SeededProviderModel[];
extends?: "claude" | "pi";
command?: string[];
}): Promise<HostSeed> {
const client = await connectAgentProfilesClient();
await client.patchDaemonConfig({
providers: {
[input.id]: {
extends: input.extends ?? "claude",
extends: "claude",
label: input.label,
description: `${input.label} test provider`,
enabled: true,
command: input.command ?? ["node"],
command: ["node"],
models: input.models,
},
},
@@ -26,7 +26,7 @@ function buildSeededStoragePayload() {
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
nowIso,
}),
preferences: buildCreateAgentPreferences(),
preferences: buildCreateAgentPreferences(getServerId()),
};
}
@@ -1,23 +1,14 @@
import type {
FormPreferences,
ProviderPreferences,
} from "../../../src/create-agent-preferences/preferences";
export const TEST_HOST_LABEL = "localhost";
export const TEST_PROVIDER_PREFERENCES = {
claude: { model: "haiku" },
codex: { model: "gpt-5.4-mini", thinkingByModel: { "gpt-5.4-mini": "low" } },
} satisfies Record<string, ProviderPreferences>;
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" },
} as const;
export function buildDirectTcpConnection(endpoint: string): {
id: string;
type: "directTcp";
endpoint: string;
} {
export function buildDirectTcpConnection(endpoint: string) {
return {
id: `direct:${endpoint}`,
type: "directTcp",
type: "directTcp" as const,
endpoint,
};
}
@@ -42,11 +33,12 @@ export function buildSeededHost(input: {
export const TEST_MOCK_PROVIDER_PREFERENCES = {
...TEST_PROVIDER_PREFERENCES,
mock: { model: "ten-second-stream" },
} satisfies Record<string, ProviderPreferences>;
} as const;
export function buildCreateAgentPreferences() {
export function buildCreateAgentPreferences(serverId: string) {
return {
provider: "mock",
serverId,
provider: "mock" as const,
providerPreferences: TEST_MOCK_PROVIDER_PREFERENCES,
} satisfies FormPreferences;
};
}
@@ -1,5 +1,4 @@
import { chmod, readFile, writeFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import type { WebSocketRoute } from "@playwright/test";
@@ -178,19 +177,6 @@ export async function expectSaveButtonDisabled(page: Page): Promise<void> {
await expect(page.getByTestId("save-button")).toBeDisabled();
}
export async function expectUncommittedSetupWarning(page: Page): Promise<void> {
const warning = page.getByRole("alert").filter({ hasText: "Commit paseo.json changes" });
await expect(warning).toContainText("Commit paseo.json changes");
await expect(warning).toContainText(
"New worktrees use the setup script from the base branch you select.",
);
}
export async function expectNoUncommittedSetupWarning(page: Page): Promise<void> {
const warning = page.getByRole("alert").filter({ hasText: "Commit paseo.json changes" });
await expect(warning).toHaveCount(0);
}
// --- Form-state assertions ---
export async function expectProjectSettingsFormVisible(page: Page): Promise<void> {
@@ -263,11 +249,6 @@ export async function restorePaseoConfig(
await writeFile(path.join(repoPath, "paseo.json"), JSON.stringify(config, null, 2) + "\n");
}
export function commitPaseoConfig(repoPath: string): void {
execFileSync("git", ["add", "paseo.json"], { cwd: repoPath });
execFileSync("git", ["commit", "-m", "Update project config"], { cwd: repoPath });
}
// The daemon writes atomically via a temp file + rename, so blocking writes requires
// removing write permission from the *directory*, not just the file.
export async function blockPaseoConfigWrites(repoPath: string): Promise<void> {
@@ -60,7 +60,7 @@ export async function connectDaemonWebAppOnlyThroughRelay(
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([storedHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ storedHost: host, preferences: buildCreateAgentPreferences() },
{ storedHost: host, preferences: buildCreateAgentPreferences(offer.serverId) },
);
const relaySocketOpened = new Promise<void>((resolve) => {
+1 -1
View File
@@ -132,7 +132,7 @@ export async function seedSavedSettingsHosts(
if (!firstHost) {
throw new Error("Expected at least one settings host fixture.");
}
const preferences = buildCreateAgentPreferences();
const preferences = buildCreateAgentPreferences(firstHost.serverId);
await page.evaluate(
({ keys, storedRegistry, storedPreferences }) => {
@@ -106,7 +106,7 @@ class StartupScenario {
if (!firstHost) {
throw new Error("Expected at least one startup test host.");
}
const createAgentPreferences = buildStoredCreateAgentPreferences();
const createAgentPreferences = buildStoredCreateAgentPreferences(firstHost.serverId);
await this.page.evaluate(
({ keys, registry: storedRegistry, createAgentPreferences: storedPreferences }) => {
@@ -245,6 +245,6 @@ function buildStoredHost(input: {
return buildSeededHost(input);
}
function buildStoredCreateAgentPreferences() {
return buildCreateAgentPreferences();
function buildStoredCreateAgentPreferences(serverId: string) {
return buildCreateAgentPreferences(serverId);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.4.0",
"version": "0.3.1",
"private": true,
"main": "index.ts",
"scripts": {
-1
View File
@@ -14,7 +14,6 @@
* here.
*/
export type { AgentProfile } from "@getpaseo/protocol/messages";
export type { MaterializedAgentProfile } from "./internal/materialize-profile";
export { useAgentProfiles } from "./internal/use-agent-profiles";
export {
useAgentProfilePicker,
@@ -1,10 +1,6 @@
import { describe, expect, it } from "vitest";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import {
materializeAgentProfile,
reconcileMaterializedProfileMode,
toAgentConfigApply,
} from "./materialize-profile";
import { materializeAgentProfile, toAgentConfigApply } from "./materialize-profile";
function profile(overrides: Partial<AgentProfile> = {}): AgentProfile {
return {
@@ -91,29 +87,3 @@ describe("toAgentConfigApply", () => {
).not.toHaveProperty("provider");
});
});
describe("reconcileMaterializedProfileMode", () => {
it("waits when the current provider mode catalog is unknown", () => {
expect(
reconcileMaterializedProfileMode(materializeAgentProfile(profile({ modeId: "plan" })), null),
).toBeNull();
});
it("drops a profile mode removed from the current provider catalog", () => {
expect(
reconcileMaterializedProfileMode(
materializeAgentProfile(profile({ modeId: "removed-mode" })),
["plan", "default"],
),
).toMatchObject({ modeId: "" });
});
it("preserves a profile mode still offered by the provider", () => {
expect(
reconcileMaterializedProfileMode(materializeAgentProfile(profile({ modeId: "plan" })), [
"plan",
"default",
]),
).toMatchObject({ modeId: "plan" });
});
});
@@ -29,19 +29,6 @@ export function materializeAgentProfile(profile: AgentProfile): MaterializedAgen
};
}
export function reconcileMaterializedProfileMode(
profile: MaterializedAgentProfile,
availableModeIds: readonly string[] | null,
): MaterializedAgentProfile | null {
if (availableModeIds === null) {
return null;
}
if (!profile.modeId || availableModeIds.includes(profile.modeId)) {
return profile;
}
return { ...profile, modeId: "" };
}
/**
* The payload for a running agent. Omitted fields are left alone by the daemon,
* so a profile that names no mode does not reset the agent's mode. Provider is
@@ -1,5 +1,7 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { mergeCreateAgentSelectionPreferences } from "@/create-agent-preferences/preferences";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
@@ -7,22 +9,21 @@ import { useSessionStore } from "@/stores/session-store";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
import {
materializeAgentProfile,
reconcileMaterializedProfileMode,
toAgentConfigApply,
type MaterializedAgentProfile,
} from "./materialize-profile";
import { materializeAgentProfile, toAgentConfigApply } from "./materialize-profile";
import { buildAgentProfileTags } from "./profile-summary";
import { useAgentProfiles } from "./use-agent-profiles";
/** The draft composer owns profile application as one state transition. */
/** The draft composer's own setters. Applying a profile drives them like a user would. */
export interface DraftAgentProfileControls {
applyProfile: (profile: MaterializedAgentProfile) => void;
selectProvider: (provider: AgentProvider) => void;
selectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
selectMode: (modeId: string) => void;
selectThinkingOption: (thinkingOptionId: string) => void;
setFeature?: (featureId: string, value: unknown) => void;
}
export type AgentProfileApplyTarget =
| { kind: "agent"; agentId: string; availableModeIds: readonly string[] | null }
| { kind: "agent"; agentId: string }
| { kind: "draft"; controls: DraftAgentProfileControls };
/** Everything the model picker renders for one profile. It never sees the profile itself. */
@@ -106,7 +107,8 @@ export function useAgentProfilePicker(
);
const persistSelection = useCallback(
(resolved: MaterializedAgentProfile) => {
(profile: AgentProfile) => {
const resolved = materializeAgentProfile(profile);
void updatePreferences((current) =>
mergeCreateAgentSelectionPreferences({
preferences: current,
@@ -133,21 +135,35 @@ export function useAgentProfilePicker(
}
const resolved = materializeAgentProfile(profile);
// Persist on both paths, before driving the controls. The draft form drops
// its local feature values when the provider changes, so preferences are
// the only channel that survives a profile that switches provider.
persistSelection(profile);
if (target.kind === "draft") {
target.controls.applyProfile(resolved);
const { controls } = target;
if (resolved.modelId) {
controls.selectProviderAndModel(resolved.provider, resolved.modelId);
} else {
controls.selectProvider(resolved.provider);
}
if (resolved.modeId) {
controls.selectMode(resolved.modeId);
}
if (resolved.thinkingOptionId) {
controls.selectThinkingOption(resolved.thinkingOptionId);
}
for (const [featureId, value] of Object.entries(resolved.featureValues)) {
controls.setFeature?.(featureId, value);
}
return;
}
const reconciled = reconcileMaterializedProfileMode(resolved, target.availableModeIds);
if (!reconciled) {
return;
}
persistSelection(reconciled);
if (!client) {
return;
}
void client
.applyAgentConfig(target.agentId, toAgentConfigApply(reconciled))
.applyAgentConfig(target.agentId, toAgentConfigApply(resolved))
.then((notice) => showProviderNoticeToast(toast, notice))
.catch((error) => {
console.warn("[useAgentProfilePicker] applyAgentConfig failed", error);
@@ -18,10 +18,6 @@ class MemoryStorage {
async setItem(key: string, value: string): Promise<void> {
this.values.set(key, value);
}
async removeItem(key: string): Promise<void> {
this.values.delete(key);
}
}
class FakeProfileHost {
@@ -2,19 +2,29 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import { z } from "zod";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { FormPreferencesSchema } from "@/create-agent-preferences/preferences";
import { readValidatedJson, readValidatedString } from "@/storage/validated-storage";
const PREFERENCES_KEY = "@paseo:create-agent-preferences";
const COMPLETION_KEY_PREFIX = "@paseo:legacy-favorites-to-agent-profiles:v1:";
const CATALOG_LOADING_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000, 4_000] as const;
type LegacyFavorite = NonNullable<z.infer<typeof FormPreferencesSchema>["favoriteModels"]>[number];
const legacyPreferencesSchema = z.object({
favoriteModels: z
.array(
z.object({
provider: z.string().trim().min(1),
modelId: z.string().trim().min(1),
}),
)
.optional(),
});
type LegacyFavorite = NonNullable<
z.infer<typeof legacyPreferencesSchema>["favoriteModels"]
>[number];
export interface LegacyFavoriteMigrationStorage {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
}
export interface LegacyFavoriteMigrationHost {
@@ -37,6 +47,18 @@ function completionKey(serverId: string): string {
return `${COMPLETION_KEY_PREFIX}${serverId}`;
}
function parseStoredPreferences(raw: string | null): LegacyFavorite[] {
if (!raw) {
return [];
}
try {
const result = legacyPreferencesSchema.safeParse(JSON.parse(raw));
return result.success ? (result.data.favoriteModels ?? []) : [];
} catch {
return [];
}
}
function favoriteKey(favorite: Pick<LegacyFavorite, "provider" | "modelId">): string {
return `${favorite.provider}\u0000${favorite.modelId}`;
}
@@ -183,16 +205,11 @@ export class LegacyFavoriteProfileMigration {
return;
}
const markerKey = completionKey(serverId);
if ((await readValidatedString(this.storage, markerKey, z.literal("1"))) === "1") {
if ((await this.storage.getItem(markerKey)) === "1") {
return;
}
const preferences = await readValidatedJson(
this.storage,
PREFERENCES_KEY,
FormPreferencesSchema,
);
const favorites = preferences?.favoriteModels ?? [];
const favorites = parseStoredPreferences(await this.storage.getItem(PREFERENCES_KEY));
if (favorites.length === 0) {
await this.storage.setItem(markerKey, "1");
return;
@@ -73,32 +73,30 @@ export function AgentProfileRow({
return (
<View style={rowStyle} testID={`agent-profile-row-${profile.id}`}>
<View style={styles.rowMain}>
<View style={styles.iconWrapper}>
<AgentProfileGlyph icon={profile.icon} color={profile.color} size={ICON_SIZE.md} />
<View style={styles.iconWrapper}>
<AgentProfileGlyph icon={profile.icon} color={profile.color} size={ICON_SIZE.md} />
</View>
<View style={settingsStyles.rowContent}>
<View style={styles.titleLine}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{profile.name}
</Text>
<Text style={styles.summary} numberOfLines={1}>
{summary}
</Text>
</View>
<View style={settingsStyles.rowContent}>
<View style={styles.titleLine}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{profile.name}
</Text>
<Text style={styles.summary} numberOfLines={1}>
{summary}
{profile.notes ? (
<View style={styles.notes}>
<ThemedFileText size={ICON_SIZE.xs} uniProps={mutedColorMapping} />
<Text
style={styles.notesText}
numberOfLines={2}
testID={`agent-profile-notes-${profile.id}`}
>
{profile.notes}
</Text>
</View>
{profile.notes ? (
<View style={styles.notes}>
<ThemedFileText size={ICON_SIZE.xs} uniProps={mutedColorMapping} />
<Text
style={styles.notesText}
numberOfLines={2}
testID={`agent-profile-notes-${profile.id}`}
>
{profile.notes}
</Text>
</View>
) : null}
</View>
) : null}
</View>
<View style={styles.rowActions}>
<Button
@@ -147,17 +145,12 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
paddingVertical: theme.spacing[3],
},
rowMain: {
flex: 1,
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[2],
},
iconWrapper: {
width: theme.iconSize.md,
alignSelf: "flex-start",
alignItems: "center",
justifyContent: "center",
marginTop: 1,
marginTop: theme.spacing[1],
},
titleLine: {
flexDirection: "row",
+38 -21
View File
@@ -1,58 +1,75 @@
import { useState, useCallback, useEffect, useId, useRef } from "react";
import { useState, useCallback, useEffect, useId } from "react";
import { type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
import { ScrollView } from "react-native-gesture-handler";
import {
type LayoutChangeEvent,
type NativeSyntheticEvent,
type NativeScrollEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
createAnimatedComponent,
useAnimatedScrollHandler,
useSharedValue,
type SharedValue,
} from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
const AnimatedScrollView = createAnimatedComponent(ScrollView);
interface DiffScrollProps {
children: React.ReactNode;
scrollViewWidth: number;
onScrollViewWidthChange: (width: number) => void;
style?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
offset: SharedValue<number>;
testID?: string;
}
export function DiffScroll({
children,
scrollViewWidth: _scrollViewWidth,
onScrollViewWidthChange,
style,
contentContainerStyle,
offset,
testID,
}: DiffScrollProps) {
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
const horizontalScroll = useHorizontalScrollOptional();
const scrollId = useId();
const scrollViewRef = useRef<ScrollViewType>(null);
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
const isAtLeftEdgeValue = useSharedValue(true);
const closeGestureRef = useFileExplorerCloseGestureRef();
// Register/unregister scroll offset tracking
useEffect(() => {
if (!horizontalScroll) return;
// Start at 0 (not scrolled)
horizontalScroll.registerScrollOffset(scrollId, 0);
return () => {
horizontalScroll.unregisterScrollOffset(scrollId);
};
}, [horizontalScroll, scrollId]);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetX = event.nativeEvent.contentOffset.x;
// Track if we're at the left edge (with small threshold for float precision)
setIsAtLeftEdge(offsetX <= 1);
const recordScrollOffset = useCallback(
(offsetX: number) => {
if (horizontalScroll) {
horizontalScroll.registerScrollOffset(scrollId, offsetX);
}
},
[horizontalScroll, scrollId],
);
const recordLeftEdge = useCallback((atLeftEdge: boolean) => {
setIsAtLeftEdge(atLeftEdge);
}, []);
const handleScroll = useAnimatedScrollHandler({
onScroll: (event) => {
const offsetX = event.contentOffset.x;
offset.value = offsetX;
const atLeftEdge = offsetX <= 1;
if (atLeftEdge !== isAtLeftEdgeValue.value) {
isAtLeftEdgeValue.value = atLeftEdge;
scheduleOnRN(recordLeftEdge, atLeftEdge);
}
scheduleOnRN(recordScrollOffset, offsetX);
},
});
const handleLayout = useCallback(
(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width),
@@ -60,8 +77,7 @@ export function DiffScroll({
);
return (
<ScrollView
ref={scrollViewRef}
<AnimatedScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
@@ -71,6 +87,7 @@ export function DiffScroll({
onScroll={handleScroll}
scrollEventThrottle={16}
onLayout={handleLayout}
testID={testID}
// When at left edge, wait for close gesture to fail before scrolling.
// The close gesture fails quickly on leftward swipes (failOffsetX=-10),
// so scrolling left works normally. On rightward swipes, close gesture
@@ -78,6 +95,6 @@ export function DiffScroll({
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
>
{children}
</ScrollView>
</AnimatedScrollView>
);
}
@@ -1,12 +1,23 @@
import { useCallback } from "react";
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
import { useCallback, useId, useLayoutEffect, useState } from "react";
import {
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
import { ScrollView } from "react-native-gesture-handler";
import type { SharedValue } from "react-native-reanimated";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
import { useFileExplorerCloseGestureRef } from "@/mobile-panels/gestures";
interface DiffScrollProps {
children: React.ReactNode;
scrollViewWidth: number;
onScrollViewWidthChange: (width: number) => void;
style?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
offset: SharedValue<number>;
testID?: string;
}
export function DiffScroll({
@@ -14,9 +25,31 @@ export function DiffScroll({
onScrollViewWidthChange,
style,
contentContainerStyle,
offset,
testID,
}: DiffScrollProps) {
const horizontalScroll = useHorizontalScrollOptional();
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
const scrollId = useId();
const closeGestureRef = useFileExplorerCloseGestureRef();
useLayoutEffect(() => {
horizontalScroll?.registerScrollOffset(scrollId, 0);
return () => horizontalScroll?.unregisterScrollOffset(scrollId);
}, [horizontalScroll, scrollId]);
const handleScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetX = event.nativeEvent.contentOffset.x;
offset.value = offsetX;
setIsAtLeftEdge(offsetX <= 1);
horizontalScroll?.registerScrollOffset(scrollId, offsetX);
},
[horizontalScroll, offset, scrollId],
);
const handleLayout = useCallback(
(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width),
(event: LayoutChangeEvent) => onScrollViewWidthChange(event.nativeEvent.layout.width),
[onScrollViewWidthChange],
);
@@ -25,9 +58,14 @@ export function DiffScroll({
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
bounces={false}
style={style}
contentContainerStyle={contentContainerStyle}
onScroll={handleScroll}
scrollEventThrottle={16}
onLayout={handleLayout}
testID={testID}
waitFor={isAtLeftEdge && closeGestureRef?.current ? closeGestureRef : undefined}
>
{children}
</ScrollView>
@@ -27,7 +27,15 @@ import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import * as Clipboard from "expo-clipboard";
import { ChevronDown, Eye, EyeOff, FilePlus, FolderPlus, RotateCw } from "lucide-react-native";
import {
ChevronDown,
Eye,
EyeOff,
FilePlus,
Folder,
FolderPlus,
RotateCw,
} from "lucide-react-native";
import { MaterialFileIcon } from "@/components/material-file-icon";
import {
TreeChevron,
@@ -81,6 +89,7 @@ const SORT_OPTIONS: { value: SortOption }[] = [
];
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedFolder = withUnistyles(Folder);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
@@ -204,9 +213,12 @@ function EntryNameInputRow({
<View style={[styles.entryRow, { paddingLeft: treeRowPaddingLeft(depth) }]}>
<TreeIndentGuides depth={depth} />
<View style={styles.entryInfo}>
<View style={styles.entryChevron}>
{kind === "directory" ? <TreeChevron expanded={false} /> : null}
</View>
<View style={styles.entryIcon}>
{kind === "directory" ? (
<TreeChevron expanded={false} />
<ThemedFolder size={WORKSPACE_TREE_ICON_SIZE} uniProps={foregroundMutedColorMapping} />
) : (
<MaterialFileIcon fileName={name || "untitled"} size={WORKSPACE_TREE_ICON_SIZE} />
)}
@@ -225,7 +237,6 @@ function EntryNameInputRow({
}
autoCapitalize="none"
autoCorrect={false}
placeholderTextColor={styles.draftPlaceholder.color}
style={styles.draftInput}
selectTextOnFocus={Boolean(initialName)}
testID="file-explorer-name-input"
@@ -370,9 +381,15 @@ function TreeRowItem({
>
<TreeIndentGuides depth={depth} />
<View ref={dragSourceRef} style={styles.entryInfo}>
<View style={styles.entryChevron}>
{isDirectory ? <DirectoryChevronIcon loading={loading} expanded={isExpanded} /> : null}
</View>
<View style={styles.entryIcon}>
{isDirectory ? (
<DirectoryChevronIcon loading={loading} expanded={isExpanded} />
<ThemedFolder
size={WORKSPACE_TREE_ICON_SIZE}
uniProps={foregroundMutedColorMapping}
/>
) : (
<MaterialFileIcon fileName={entry.name} size={WORKSPACE_TREE_ICON_SIZE} />
)}
@@ -1755,6 +1772,13 @@ const styles = StyleSheet.create((theme) => ({
gap: WORKSPACE_TREE_ICON_LABEL_GAP,
minWidth: 0,
},
entryChevron: {
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
entryIcon: {
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,
@@ -1775,9 +1799,6 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: 0,
paddingHorizontal: 0,
},
draftPlaceholder: {
color: theme.colors.foregroundExtraMuted,
},
contextMetaBlock: {
paddingVertical: theme.spacing[1],
},
+80 -2
View File
@@ -1,5 +1,4 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { TaskListRow } from "@/components/task-list-row";
import {
View,
Text,
@@ -167,6 +166,7 @@ const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>;
const ThemedMicVocal = withUnistyles(MicVocal);
const ThemedTodoCheckIcon = withUnistyles(Check);
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
const ThemedChevronRightIcon = withUnistyles(ChevronRight);
@@ -179,6 +179,9 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
const mutedForegroundColorMapping = (theme: Theme) => ({
color: theme.colors.mutedForeground,
});
const primaryForegroundColorMapping = (theme: Theme) => ({
color: theme.colors.primaryForeground,
});
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
@@ -2236,6 +2239,12 @@ interface TodoListCardProps {
disableOuterSpacing?: boolean;
}
interface TodoListItemRowProps {
text: string;
completed: boolean;
status?: TodoEntry["status"];
}
function taskActivityIcon(activity: TaskActivity) {
switch (activity.type) {
case "added":
@@ -2251,6 +2260,34 @@ function taskActivityIcon(activity: TaskActivity) {
}
}
function TodoListItemRow({ text, completed, status }: TodoListItemRowProps) {
const badgeStyle = useMemo(
() => [
todoListCardStylesheet.radioBadge,
completed
? todoListCardStylesheet.radioBadgeComplete
: todoListCardStylesheet.radioBadgeIncomplete,
],
[completed],
);
const textStyle = useMemo(
() => [todoListCardStylesheet.itemText, completed && todoListCardStylesheet.itemTextCompleted],
[completed],
);
return (
<View style={todoListCardStylesheet.itemRow}>
<View
style={[badgeStyle, status === "in_progress" && todoListCardStylesheet.radioBadgeActive]}
>
{completed ? (
<ThemedTodoCheckIcon size={12} uniProps={primaryForegroundColorMapping} />
) : null}
</View>
<Text style={textStyle}>{text}</Text>
</View>
);
}
const todoListCardStylesheet = StyleSheet.create((theme) => ({
detailsWrapper: {
padding: theme.spacing[2],
@@ -2258,6 +2295,40 @@ const todoListCardStylesheet = StyleSheet.create((theme) => ({
list: {
gap: theme.spacing[1],
},
itemRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
radioBadge: {
width: 16,
height: 16,
borderRadius: 8,
backgroundColor: theme.colors.foregroundMuted,
alignItems: "center",
justifyContent: "center",
},
radioBadgeIncomplete: {
opacity: 0.55,
},
radioBadgeComplete: {
opacity: 0.95,
},
radioBadgeActive: {
borderWidth: 2,
borderColor: theme.colors.primary,
backgroundColor: "transparent",
opacity: 1,
},
itemText: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
itemTextCompleted: {
color: theme.colors.foregroundMuted,
textDecorationLine: "line-through",
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
@@ -2295,7 +2366,14 @@ export const TodoListCard = memo(function TodoListCard({
{items.length === 0 ? (
<Text style={todoListCardStylesheet.emptyText}>{t("message.todo.empty")}</Text>
) : (
items.map((item) => <TaskListRow key={item.id ?? item.text} task={item} />)
items.map((item) => (
<TodoListItemRow
key={item.id ?? item.text}
text={item.text}
completed={item.completed}
status={item.status}
/>
))
)}
</View>
</View>
@@ -70,7 +70,7 @@ describe("model browser initial view", () => {
).toEqual({ kind: "all" });
});
it("opens a sole provider directly regardless of root content", () => {
it("keeps the root even when a single provider would otherwise be skipped", () => {
expect(
resolveInitialModelBrowserView({
providers: [pi],
@@ -78,7 +78,7 @@ describe("model browser initial view", () => {
selectedModel: "pi-pro",
hasProfiles: true,
}),
).toEqual({ kind: "provider", providerId: "pi", providerLabel: "Pi" });
).toEqual({ kind: "all" });
});
it("falls back to the root when the selected provider is gone", () => {
@@ -32,7 +32,11 @@ export function resolveModelBrowserAllView({
return { kind: "searchResults", rows };
}
/** Where the picker lands when it opens. A sole provider skips the redundant root view. */
/**
* Where the picker lands when it opens. Agent profiles live on the root view, so
* a host that has any profile always opens there — including the single-provider
* case, which would otherwise skip the root entirely and hide them.
*/
export function resolveInitialModelBrowserView({
providers,
selectedProvider,
@@ -44,6 +48,10 @@ export function resolveInitialModelBrowserView({
selectedModel: string;
hasProfiles: boolean;
}): ModelBrowserView {
if (hasProfiles) {
return { kind: "all" };
}
const singleProvider = providers.length === 1 ? providers[0] : undefined;
if (singleProvider) {
return {
@@ -53,10 +61,6 @@ export function resolveInitialModelBrowserView({
};
}
if (hasProfiles) {
return { kind: "all" };
}
if (selectedProvider.length > 0 && selectedModel.length > 0) {
const provider = providers.find((entry) => entry.id === selectedProvider);
if (provider) {
+5 -13
View File
@@ -114,7 +114,7 @@ function AgentProfilesEditAction({ onPress }: { onPress: () => void }) {
accessibilityLabel={t("modelSelector.editProfilesLabel")}
testID="model-profiles-edit"
>
<ThemedPencil size={ICON_SIZE.xs} uniProps={foregroundExtraMutedMapping} />
<ThemedPencil size={ICON_SIZE.sm} uniProps={foregroundExtraMutedMapping} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
@@ -292,7 +292,9 @@ export function useModelBrowser({
setSearchQuery(value);
}, []);
const singleProviderView = providers.length === 1;
// A pinned profiles section makes the root worth returning to, so the back
// affordance stays even when the drill-down was the only provider.
const singleProviderView = providers.length === 1 && !hasProfiles;
const header = useMemo<SheetHeader>(() => {
if (view.kind === "all") {
return {
@@ -673,11 +675,7 @@ function AgentProfilesPickerSection({
<View style={styles.profilesContainer}>
<View style={styles.sectionHeading}>
<Text style={styles.sectionHeadingText}>{t("modelSelector.profiles")}</Text>
{onEditProfiles ? (
<View style={styles.sectionHeadingAction}>
<AgentProfilesEditAction onPress={onEditProfiles} />
</View>
) : null}
{onEditProfiles ? <AgentProfilesEditAction onPress={onEditProfiles} /> : null}
</View>
{rows.map((row) => (
<AgentProfilePickerRowView key={row.id} row={row} onApply={handleApply} />
@@ -1219,7 +1217,6 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.border,
},
sectionHeading: {
position: "relative",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
@@ -1227,11 +1224,6 @@ const styles = StyleSheet.create((theme) => ({
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
},
sectionHeadingAction: {
position: "absolute",
top: theme.spacing[1],
right: isWeb ? theme.spacing[3] : theme.spacing[6],
},
sectionHeadingText: {
flex: 1,
fontSize: theme.fontSize.xs,
@@ -1,115 +0,0 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 2: 8 },
fontSize: { sm: 13 },
colors: {
foreground: "#foreground",
foregroundMuted: "#muted",
foregroundExtraMuted: "#extra-muted",
statusDotRunning: "#running",
},
},
}));
vi.mock("react-native", () => ({
View: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) =>
React.createElement("div", props, children),
Text: ({ children, style }: React.PropsWithChildren<{ style?: Record<string, unknown>[] }>) => {
const resolvedStyle = Object.assign({}, ...(style ?? []).filter(Boolean));
return React.createElement(
"span",
{
"data-color": resolvedStyle.color,
"data-decoration": resolvedStyle.textDecorationLine,
},
children,
);
},
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: (value: typeof theme) => unknown) => factory(theme),
},
withUnistyles:
(Component: React.ComponentType<Record<string, unknown>>) =>
({ uniProps, ...props }: Record<string, unknown>) => {
const themedProps = (uniProps as (value: typeof theme) => Record<string, unknown>)(theme);
return React.createElement(Component, { ...props, ...themedProps });
},
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
Circle: createIcon("Circle"),
CircleCheck: createIcon("CircleCheck"),
CircleDot: createIcon("CircleDot"),
};
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { TaskListRow } from "./task-list-row";
const pendingTask = { text: "Queued", completed: false } as const;
const runningTask = {
text: "Run checks",
activeForm: "Running checks",
completed: false,
status: "in_progress",
} as const;
const completedTask = { text: "Finished", completed: true, status: "completed" } as const;
describe("TaskListRow", () => {
let container: HTMLElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("keeps pending tasks muted", () => {
act(() => root.render(<TaskListRow task={pendingTask} />));
expect(container.querySelector('[data-icon="Circle"]')?.getAttribute("color")).toBe("#muted");
expect(container.querySelector("[data-color]")?.getAttribute("data-color")).toBe("#muted");
});
it("foregrounds only the running task with the running blue circle dot", () => {
act(() => root.render(<TaskListRow task={runningTask} />));
expect(container.textContent).toBe("Running checks");
expect(container.querySelector('[data-icon="CircleDot"]')?.getAttribute("color")).toBe(
"#running",
);
expect(container.querySelector("[data-color]")?.getAttribute("data-color")).toBe("#foreground");
});
it("extra-mutes completed tasks with a circle check", () => {
act(() => root.render(<TaskListRow task={completedTask} />));
expect(container.querySelector('[data-icon="CircleCheck"]')?.getAttribute("color")).toBe(
"#extra-muted",
);
const text = container.querySelector("[data-color]");
expect(text?.getAttribute("data-color")).toBe("#extra-muted");
expect(text?.getAttribute("data-decoration")).toBe("line-through");
});
});
@@ -1,62 +0,0 @@
import { Circle, CircleCheck, CircleDot } from "lucide-react-native";
import { memo } from "react";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import type { TodoEntry } from "@/types/stream";
const ThemedCircle = withUnistyles(Circle);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const extraMutedIcon = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted });
const mutedIcon = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const runningIcon = (theme: Theme) => ({ color: theme.colors.statusDotRunning });
function TaskStatusIcon({ isCompleted, isRunning }: { isCompleted: boolean; isRunning: boolean }) {
if (isCompleted) {
return <ThemedCircleCheck size={16} uniProps={extraMutedIcon} />;
}
if (isRunning) {
return <ThemedCircleDot size={16} uniProps={runningIcon} />;
}
return <ThemedCircle size={16} uniProps={mutedIcon} />;
}
export const TaskListRow = memo(function TaskListRow({ task }: { task: TodoEntry }) {
const isCompleted = task.completed || task.status === "completed";
const isRunning = !isCompleted && task.status === "in_progress";
const text = isRunning && task.activeForm ? task.activeForm : task.text;
return (
<View style={styles.row} accessibilityLabel={text}>
<TaskStatusIcon isCompleted={isCompleted} isRunning={isRunning} />
<Text
numberOfLines={1}
style={[styles.text, isRunning && styles.runningText, isCompleted && styles.completedText]}
>
{text}
</Text>
</View>
);
});
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
text: {
flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
runningText: {
color: theme.colors.foreground,
},
completedText: {
color: theme.colors.foregroundExtraMuted,
textDecorationLine: "line-through",
},
}));
@@ -10,22 +10,22 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
// paths) render different data, but their ROWS should look identical — same
// indentation, guide lines, and chevron. Keep those here so the two trees can't
// drift apart.
export const TREE_INDENT_PER_LEVEL = 16;
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
export const WORKSPACE_TREE_ICON_SIZE = 16;
export const WORKSPACE_TREE_LOADING_ICON_SIZE = 14;
export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[2];
export const TREE_INDENT_PER_LEVEL = 14;
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1];
export const WORKSPACE_TREE_ICON_SIZE = 14;
export const WORKSPACE_TREE_LOADING_ICON_SIZE = 12;
export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[1];
/**
* Trailing glyph rail shared with the explorer X and Changes options chevron.
* The extra 2px is optical: text ink ends inside its layout box, while the
* header icons' strokes extend to theirs.
*/
export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[4] + 2;
export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[3] + 2;
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
* in the Changes tree so their indentation can't drift apart. */
export function treeRowPaddingLeft(depth: number): number {
return SPACING[3] + depth * TREE_INDENT_PER_LEVEL;
return SPACING[2] + depth * TREE_INDENT_PER_LEVEL;
}
const foregroundExtraMutedIconColorMapping = (theme: Theme) => ({
@@ -46,7 +46,7 @@ export function TreeIndentGuides({ depth }: { depth: number }) {
key: index,
style: [
styles.indentGuide,
inlineUnistylesStyle({ left: SPACING[3] + index * TREE_INDENT_PER_LEVEL + 4 }),
inlineUnistylesStyle({ left: SPACING[2] + index * TREE_INDENT_PER_LEVEL + 3 }),
],
})),
[depth],
@@ -81,7 +81,6 @@ import {
useAgentProfilePicker,
type AgentProfileApplyTarget,
type AgentProfilePicker,
type DraftAgentProfileControls,
} from "@/agent-profiles";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
@@ -126,6 +125,7 @@ interface ControlledAgentControlsProps {
export interface DraftAgentControlsProps {
providerDefinitions: AgentProviderDefinition[];
selectedProvider: AgentProvider | null;
onSelectProvider: (provider: AgentProvider) => void;
modeOptions: AgentMode[];
selectedMode: string;
onSelectMode: (modeId: string) => void;
@@ -139,7 +139,6 @@ export interface DraftAgentControlsProps {
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
selectedThinkingOptionId: string;
onSelectThinkingOption: (thinkingOptionId: string) => void;
onApplyAgentProfile: DraftAgentProfileControls["applyProfile"];
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void;
@@ -386,15 +385,6 @@ function resolveSnapshotSelectedEntry(
return snapshotEntries.find((e) => e.provider === agentProvider) ?? null;
}
function resolveSnapshotModeIds(
entry: ReturnType<typeof resolveSnapshotSelectedEntry>,
): string[] | null {
if (entry?.status !== "ready" || !entry.modes) {
return null;
}
return entry.modes.map((mode) => mode.id);
}
function buildAgentProviderDefinitions(
agentProvider: string | undefined,
snapshotEntries: ReturnType<typeof useProvidersSnapshot>["entries"],
@@ -1560,13 +1550,9 @@ export const AgentControls = memo(function AgentControls({
// A running agent is one provider's process, so only that provider's profiles
// can apply to it.
const profileProviders = useMemo(() => (agentProvider ? [agentProvider] : []), [agentProvider]);
const profileModeIds = useMemo(
() => resolveSnapshotModeIds(snapshotSelectedEntry),
[snapshotSelectedEntry],
);
const profileTarget = useMemo<AgentProfileApplyTarget>(
() => ({ kind: "agent", agentId, availableModeIds: profileModeIds }),
[agentId, profileModeIds],
() => ({ kind: "agent", agentId }),
[agentId],
);
const agentProfiles = useAgentProfilePicker({
serverId,
@@ -1706,6 +1692,7 @@ export const AgentControls = memo(function AgentControls({
export function DraftAgentControls({
providerDefinitions,
selectedProvider,
onSelectProvider,
modeOptions,
selectedMode,
onSelectMode,
@@ -1719,7 +1706,6 @@ export function DraftAgentControls({
thinkingOptions,
selectedThinkingOptionId,
onSelectThinkingOption,
onApplyAgentProfile,
features,
onSetFeature,
onDropdownClose,
@@ -1756,10 +1742,20 @@ export function DraftAgentControls({
() => ({
kind: "draft",
controls: {
applyProfile: onApplyAgentProfile,
selectProvider: onSelectProvider,
selectProviderAndModel: onSelectProviderAndModel,
selectMode: onSelectMode,
selectThinkingOption: onSelectThinkingOption,
setFeature: onSetFeature,
},
}),
[onApplyAgentProfile],
[
onSelectMode,
onSelectProvider,
onSelectProviderAndModel,
onSelectThinkingOption,
onSetFeature,
],
);
const agentProfiles = useAgentProfilePicker({
serverId: modelSelectorServerId,
@@ -20,6 +20,7 @@ describe("resolveAgentControlsMode", () => {
resolveAgentControlsMode({
providerDefinitions: [],
selectedProvider: "codex",
onSelectProvider: () => undefined,
modeOptions: [],
selectedMode: "",
onSelectMode: () => undefined,
@@ -33,7 +34,6 @@ describe("resolveAgentControlsMode", () => {
thinkingOptions: [],
selectedThinkingOptionId: "",
onSelectThinkingOption: () => undefined,
onApplyAgentProfile: () => undefined,
}),
).toBe("draft");
});
@@ -22,13 +22,13 @@ export function buildDraftAgentControls(input: {
formState: UseAgentFormStateResult;
features?: DraftAgentControlsProps["features"];
onSetFeature?: DraftAgentControlsProps["onSetFeature"];
onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"];
onDropdownClose?: DraftAgentControlsProps["onDropdownClose"];
}): DraftAgentControlsProps {
const { formState, features, onSetFeature, onApplyAgentProfile, onDropdownClose } = input;
const { formState, features, onSetFeature, onDropdownClose } = input;
return {
providerDefinitions: formState.providerDefinitions,
selectedProvider: formState.selectedProvider,
onSelectProvider: formState.setProviderFromUser,
modeOptions: formState.modeOptions,
selectedMode: formState.selectedMode,
onSelectMode: formState.setModeFromUser,
@@ -42,7 +42,6 @@ export function buildDraftAgentControls(input: {
thinkingOptions: formState.availableThinkingOptions,
selectedThinkingOptionId: formState.selectedThinkingOptionId,
onSelectThinkingOption: formState.setThinkingOptionFromUser,
onApplyAgentProfile,
features,
onSetFeature,
onDropdownClose,
@@ -190,7 +190,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
features: draftFeatures,
featureValues: draftFeatureValues,
setFeatureValue: setDraftFeatureValue,
applyProfileFeatureValues,
} = useDraftAgentFeatures({
serverId: formState.selectedServerId,
provider: formState.selectedProvider,
@@ -201,14 +200,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
initialFeatureValues: composerOptions?.initialFeatureValues,
});
const applyDraftAgentProfile = useCallback(
(profile: Parameters<typeof formState.applyProfileFromUser>[0]) => {
formState.applyProfileFromUser(profile);
applyProfileFeatureValues(profile.featureValues);
},
[applyProfileFeatureValues, formState],
);
const commandDraftConfig = useMemo(
() =>
composerOptions
@@ -245,7 +236,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
formState,
features: draftFeatures,
onSetFeature: setDraftFeatureValue,
onApplyAgentProfile: applyDraftAgentProfile,
}),
commandDraftConfig,
};
@@ -256,7 +246,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
effectiveThinkingOptionId,
draftFeatures,
draftFeatureValues,
applyDraftAgentProfile,
formState,
setDraftFeatureValue,
workingDir,
+56 -9
View File
@@ -1,14 +1,21 @@
import { memo, useCallback, useMemo, useState } from "react";
import { View } from "react-native";
import { ChevronDown, ChevronRight } from "lucide-react-native";
import { Text, View } from "react-native";
import { Check, ChevronDown, ChevronRight, Circle, CircleDot } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { TaskListRow } from "@/components/task-list-row";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { useSessionStore } from "@/stores/session-store";
import type { Theme } from "@/styles/theme";
import type { TodoEntry } from "@/types/stream";
const ThemedCheck = withUnistyles(Check);
const ThemedCircle = withUnistyles(Circle);
const ThemedCircleDot = withUnistyles(CircleDot);
const mutedIcon = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const activeIcon = (theme: Theme) => ({ color: theme.colors.statusWarning });
const completedIcon = (theme: Theme) => ({ color: theme.colors.statusSuccess });
export const AgentTaskList = memo(function AgentTaskList({
serverId,
agentId,
@@ -21,6 +28,16 @@ export const AgentTaskList = memo(function AgentTaskList({
return <TaskListCard tasks={tasks} />;
});
function TaskStatusIcon({ task }: { task: TodoEntry }) {
if (task.completed || task.status === "completed") {
return <ThemedCheck size={15} uniProps={completedIcon} />;
}
if (task.status === "in_progress") {
return <ThemedCircleDot size={15} uniProps={activeIcon} />;
}
return <ThemedCircle size={15} uniProps={mutedIcon} />;
}
const TaskListCard = memo(function TaskListCard({ tasks }: { tasks: TodoEntry[] }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
@@ -66,9 +83,25 @@ const TaskListCard = memo(function TaskListCard({ tasks }: { tasks: TodoEntry[]
</Button>
{expanded ? (
<View style={styles.list}>
{tasks.map((task, index) => (
<TaskListRow key={task.id ?? `${index}:${task.text}`} task={task} />
))}
{tasks.map((task, index) => {
const isActive = task.status === "in_progress";
const text = isActive && task.activeForm ? task.activeForm : task.text;
return (
<View
key={task.id ?? `${index}:${task.text}`}
style={styles.row}
accessibilityLabel={text}
>
<TaskStatusIcon task={task} />
<Text
numberOfLines={1}
style={[styles.taskText, task.completed && styles.completedText]}
>
{text}
</Text>
</View>
);
})}
</View>
) : null}
</View>
@@ -109,8 +142,22 @@ const styles = StyleSheet.create((theme) => ({
list: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
paddingTop: theme.spacing[1],
},
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingTop: theme.spacing[2],
gap: theme.spacing[1],
paddingVertical: theme.spacing[1],
},
taskText: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
completedText: {
color: theme.colors.foregroundMuted,
textDecorationLine: "line-through",
},
}));
@@ -13,9 +13,9 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import {
clearSidebarCallouts,
createSidebarCalloutState,
DismissedCalloutKeysSchema,
dismissSidebarCallout,
loadDismissedCalloutKeys,
parseDismissedCalloutKeys,
selectActiveSidebarCallout,
serializeDismissedCalloutKeys,
showSidebarCallout,
@@ -24,7 +24,6 @@ import {
type SidebarCalloutState,
unregisterSidebarCallout,
} from "./sidebar-callout-state";
import { readValidatedJson } from "@/storage/validated-storage";
export type { SidebarCalloutOptions } from "./sidebar-callout-state";
@@ -63,12 +62,8 @@ export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
async function loadDismissedKeys(): Promise<void> {
let dismissedKeys: ReadonlySet<string>;
try {
const value = await readValidatedJson(
AsyncStorage,
DISMISSED_CALLOUTS_STORAGE_KEY,
DismissedCalloutKeysSchema,
);
dismissedKeys = new Set(value ?? []);
const value = await AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY);
dismissedKeys = parseDismissedCalloutKeys(value);
} catch (error) {
console.error("[SidebarCallouts] Failed to load dismissed callouts", error);
dismissedKeys = stateRef.current.dismissedKeys;
@@ -4,6 +4,7 @@ import {
createSidebarCalloutState,
dismissSidebarCallout,
loadDismissedCalloutKeys,
parseDismissedCalloutKeys,
selectActiveSidebarCallout,
serializeDismissedCalloutKeys,
showSidebarCallout,
@@ -117,6 +118,12 @@ describe("sidebar callout state", () => {
expect(selectActiveSidebarCallout(state)?.title).toBe("Update available");
});
it("parses stored dismissal keys defensively", () => {
expect(parseDismissedCalloutKeys(JSON.stringify(["a", 4, "b"]))).toEqual(new Set(["a", "b"]));
expect(parseDismissedCalloutKeys("{")).toEqual(new Set());
expect(parseDismissedCalloutKeys(JSON.stringify({ key: "a" }))).toEqual(new Set());
});
it("clears visible callouts without dropping dismissal state", () => {
let state = loadDismissedCalloutKeys(createSidebarCalloutState(), new Set(["dismissed"]));
state = showSidebarCallout(state, { id: "visible", title: "Visible" }).state;
@@ -1,5 +1,4 @@
import type { ReactNode } from "react";
import { z } from "zod";
import type { SidebarCalloutAction, SidebarCalloutVariant } from "@/components/sidebar-callout";
export interface SidebarCalloutOptions {
@@ -45,7 +44,21 @@ export function normalizeDismissalKey(key: string | null | undefined): string |
return trimmed ? trimmed : null;
}
export const DismissedCalloutKeysSchema = z.array(z.string());
export function parseDismissedCalloutKeys(value: string | null): Set<string> {
if (!value) {
return new Set();
}
try {
const parsed = JSON.parse(value) as unknown;
if (!Array.isArray(parsed)) {
return new Set();
}
return new Set(parsed.filter((entry): entry is string => typeof entry === "string"));
} catch {
return new Set();
}
}
export function serializeDismissedCalloutKeys(keys: ReadonlySet<string>): string {
return JSON.stringify([...keys]);
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import { CreateAgentPreferencesService } from "./service";
import {
applyAgentProfilePreferences,
mergeCreateAgentSelectionPreferences,
mergeProviderPreferences,
parseFormPreferences,
@@ -134,84 +133,10 @@ describe("create agent preferences", () => {
});
});
it("erases a saved mode when a complete selection explicitly has no mode", () => {
expect(
mergeCreateAgentSelectionPreferences({
preferences: {
provider: "pi",
providerPreferences: { pi: { model: "anthropic/sonnet", mode: "full-access" } },
},
provider: "pi",
modelId: "anthropic/sonnet",
modeId: null,
}),
).toEqual({
provider: "pi",
providerPreferences: { pi: { model: "anthropic/sonnet" } },
});
});
it("repairs the previous provider while applying a profile", () => {
expect(
applyAgentProfilePreferences({
preferences: {
provider: "pi",
providerPreferences: {
pi: { model: "anthropic/sonnet", mode: "full-access" },
mock: { model: "ten-second-stream", mode: "load-test" },
},
},
previousProvider: "pi",
previousProviderModeIds: [],
provider: "mock",
modelId: "one-minute-stream",
modeId: "approval-test",
thinkingOptionId: "",
featureValues: {},
}),
).toEqual({
provider: "mock",
providerPreferences: {
pi: { model: "anthropic/sonnet" },
mock: { model: "one-minute-stream", mode: "approval-test", featureValues: {} },
},
});
});
it("loads invalid stored preferences as empty preferences", () => {
expect(parseFormPreferences({ providerPreferences: { codex: { mode: 42 } } })).toEqual({});
});
it("strips the explicitly supported legacy location fields", () => {
expect(
parseFormPreferences({
workingDir: "/old/workspace",
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4-mini",
mode: "full-access",
thinkingOptionId: "high",
},
},
serverId: "old-host",
}),
).toEqual({
provider: "codex",
providerPreferences: {
codex: {
model: "gpt-5.4-mini",
mode: "full-access",
thinkingByModel: { "gpt-5.4-mini": "high" },
},
},
});
});
it("rejects unknown persisted fields outside the explicit legacy shape", () => {
expect(parseFormPreferences({ provider: "codex", surprise: true })).toEqual({});
});
it("persists and reloads the workspace isolation choice", async () => {
const storage = new FakeCreateAgentPreferenceStorage();
const preferences = new CreateAgentPreferencesService(storage);
@@ -1,38 +1,19 @@
import { z } from "zod";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
const featureValuesSchema = z.record(z.string(), z.union([z.boolean(), z.string(), z.null()]));
export interface ProviderPreferences {
model?: string;
mode?: string;
thinkingByModel?: Record<string, string>;
featureValues?: Record<string, unknown>;
}
export type LaunchTarget = { kind: "chat" } | { kind: "terminal"; profileId: string };
export interface FormPreferences {
provider?: string;
providerPreferences?: Record<string, ProviderPreferences>;
favoriteModels?: Array<{ provider: string; modelId: string }>;
isolation?: "local" | "worktree";
launchTarget?: LaunchTarget;
}
const providerPreferencesSchema: z.ZodType<ProviderPreferences> = z.strictObject({
const providerPreferencesSchema = z.object({
model: z.string().optional(),
mode: z.string().optional(),
thinkingByModel: z.record(z.string(), z.string()).optional(),
featureValues: featureValuesSchema.optional(),
featureValues: z.record(z.string(), z.unknown()).optional(),
});
const launchTargetSchema: z.ZodType<LaunchTarget> = z.discriminatedUnion("kind", [
z.strictObject({ kind: z.literal("chat") }),
z.strictObject({ kind: z.literal("terminal"), profileId: z.string() }),
const launchTargetSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("chat") }),
z.object({ kind: z.literal("terminal"), profileId: z.string() }),
]);
export const FormPreferencesSchema = z.strictObject({
const formPreferencesSchema = z.object({
provider: z.string().optional(),
providerPreferences: z.record(z.string(), providerPreferencesSchema).optional(),
// COMPAT(agentProfileFavoriteMigration): favourites were removed in v0.3.2.
@@ -40,7 +21,7 @@ export const FormPreferencesSchema = z.strictObject({
// import it; ordinary preference writes must not erase it first.
favoriteModels: z
.array(
z.strictObject({
z.object({
provider: z.string(),
modelId: z.string(),
}),
@@ -50,50 +31,16 @@ export const FormPreferencesSchema = z.strictObject({
// What the New workspace composer submits to: the chat agent (default) or a
// terminal profile. See `@/new-workspace-launch` for resolution/fallback.
launchTarget: launchTargetSchema.optional(),
}) satisfies z.ZodType<FormPreferences>;
const LegacyProviderPreferencesSchema = z.strictObject({
model: z.string().optional(),
mode: z.string().optional(),
thinkingOptionId: z.string().optional(),
});
const LegacyFormPreferencesSchema = z
.strictObject({
workingDir: z.string().optional(),
provider: z.string().optional(),
serverId: z.string().optional(),
providerPreferences: z.record(z.string(), LegacyProviderPreferencesSchema).optional(),
})
.transform(({ provider, providerPreferences }): FormPreferences => {
const migratedProviderPreferences: Record<string, ProviderPreferences> = {};
for (const [providerId, legacy] of Object.entries(providerPreferences ?? {})) {
const model = legacy.model;
migratedProviderPreferences[providerId] = {
...(model !== undefined ? { model } : {}),
...(legacy.mode !== undefined ? { mode: legacy.mode } : {}),
...(model !== undefined && legacy.thinkingOptionId !== undefined
? { thinkingByModel: { [model]: legacy.thinkingOptionId } }
: {}),
};
}
return {
...(provider !== undefined ? { provider } : {}),
...(providerPreferences !== undefined
? { providerPreferences: migratedProviderPreferences }
: {}),
};
});
export const StoredFormPreferencesSchema: z.ZodType<FormPreferences> = z.union([
FormPreferencesSchema,
LegacyFormPreferencesSchema,
]);
export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>;
export type FormPreferences = z.infer<typeof formPreferencesSchema>;
export type LaunchTarget = z.infer<typeof launchTargetSchema>;
export const DEFAULT_FORM_PREFERENCES: FormPreferences = {};
export function parseFormPreferences(value: unknown): FormPreferences {
const result = StoredFormPreferencesSchema.safeParse(value);
const result = formPreferencesSchema.safeParse(value);
return result.success ? result.data : DEFAULT_FORM_PREFERENCES;
}
@@ -112,7 +59,7 @@ function mergeDefinedRecord<T>(
function applyProviderPreferenceUpdates(
existing: ProviderPreferences,
updates: Omit<Partial<ProviderPreferences>, "mode"> & { mode?: string | null },
updates: Partial<ProviderPreferences>,
): ProviderPreferences {
const next: ProviderPreferences = { ...existing };
const nextThinkingByModel = mergeDefinedRecord(existing.thinkingByModel, updates.thinkingByModel);
@@ -121,9 +68,7 @@ function applyProviderPreferenceUpdates(
if (updates.model !== undefined) {
next.model = updates.model;
}
if (updates.mode === null) {
delete next.mode;
} else if (updates.mode !== undefined) {
if (updates.mode !== undefined) {
next.mode = updates.mode;
}
if (nextThinkingByModel !== undefined) {
@@ -139,7 +84,7 @@ function applyProviderPreferenceUpdates(
export function mergeProviderPreferences(args: {
preferences: FormPreferences;
provider: AgentProvider;
updates: Omit<Partial<ProviderPreferences>, "mode"> & { mode?: string | null };
updates: Partial<ProviderPreferences>;
}): FormPreferences {
const { preferences, provider, updates } = args;
const existingProviderPreferences = preferences.providerPreferences ?? {};
@@ -170,52 +115,15 @@ export function mergeCreateAgentSelectionPreferences(args: {
const modelId = args.modelId?.trim() ?? "";
const modeId = args.modeId?.trim() ?? "";
const thinkingOptionId = args.thinkingOptionId?.trim() ?? "";
const featureValues = featureValuesSchema.safeParse(args.featureValues);
return mergeProviderPreferences({
preferences: args.preferences,
provider: args.provider,
updates: {
model: modelId || undefined,
mode: args.modeId === undefined ? undefined : modeId || null,
mode: modeId || undefined,
...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}),
...(featureValues.success ? { featureValues: featureValues.data } : {}),
},
});
}
export function applyAgentProfilePreferences(args: {
preferences: FormPreferences;
previousProvider: AgentProvider | null;
previousProviderModeIds: readonly string[];
provider: AgentProvider;
modelId: string;
modeId: string;
thinkingOptionId: string;
featureValues: Record<string, unknown>;
}): FormPreferences {
let next = args.preferences;
if (args.previousProvider) {
const previousMode = next.providerPreferences?.[args.previousProvider]?.mode;
if (previousMode && !args.previousProviderModeIds.includes(previousMode)) {
next = mergeProviderPreferences({
preferences: next,
provider: args.previousProvider,
updates: { mode: null },
});
}
}
return mergeProviderPreferences({
preferences: next,
provider: args.provider,
updates: {
model: args.modelId || undefined,
mode: args.modeId || null,
...(args.modelId && args.thinkingOptionId
? { thinkingByModel: { [args.modelId]: args.thinkingOptionId } }
: {}),
featureValues: args.featureValues,
...(args.featureValues ? { featureValues: args.featureValues } : {}),
},
});
}
@@ -1,10 +1,5 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { readValidatedJson } from "@/storage/validated-storage";
import {
FormPreferencesSchema,
StoredFormPreferencesSchema,
type FormPreferences,
} from "./preferences";
import type { FormPreferences } from "./preferences";
export const CREATE_AGENT_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
@@ -15,19 +10,19 @@ export interface CreateAgentPreferenceStorage {
export class AsyncStorageCreateAgentPreferenceStorage implements CreateAgentPreferenceStorage {
async read(): Promise<unknown> {
return readValidatedJson(
AsyncStorage,
CREATE_AGENT_PREFERENCES_STORAGE_KEY,
StoredFormPreferencesSchema,
);
const stored = await AsyncStorage.getItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY);
if (!stored) {
return null;
}
try {
return JSON.parse(stored);
} catch {
return null;
}
}
async write(preferences: FormPreferences): Promise<void> {
const result = FormPreferencesSchema.safeParse(preferences);
if (!result.success) {
await AsyncStorage.removeItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY);
return;
}
await AsyncStorage.setItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY, JSON.stringify(result.data));
await AsyncStorage.setItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY, JSON.stringify(preferences));
}
}
@@ -73,5 +73,5 @@ export class FakeCreateAgentPreferenceStorage implements CreateAgentPreferenceSt
}
function clone<T>(value: T): T {
return structuredClone(value);
return JSON.parse(JSON.stringify(value)) as T;
}
@@ -6,7 +6,6 @@ import {
type CompactProviderSnapshot,
} from "@getpaseo/protocol/provider-snapshot-codec";
import { CompactProviderSnapshotSchema } from "@getpaseo/protocol/messages";
import { z } from "zod";
const CACHE_VERSION = 1;
const CACHE_KEY_PREFIX = "@paseo/provider-snapshot/v1";
@@ -45,13 +44,6 @@ interface ProviderSnapshotIndex {
entries: ProviderSnapshotIndexEntry[];
}
const StoredProviderSnapshotSchema: z.ZodType<StoredProviderSnapshot> = z.strictObject({
version: z.literal(CACHE_VERSION),
hash: z.string(),
generatedAt: z.string().datetime({ offset: true }),
compactSnapshot: CompactProviderSnapshotSchema,
});
export interface CachedProviderSnapshot extends StoredProviderSnapshot {
entries: ProviderSnapshotEntry[];
}
@@ -89,8 +81,22 @@ function oldestFirst(left: ProviderSnapshotIndexEntry, right: ProviderSnapshotIn
function parseStoredProviderSnapshot(value: string): StoredProviderSnapshot | null {
const parsed: unknown = JSON.parse(value);
const result = StoredProviderSnapshotSchema.safeParse(parsed);
return result.success ? result.data : null;
if (typeof parsed !== "object" || parsed === null) return null;
const version = Reflect.get(parsed, "version");
const hash = Reflect.get(parsed, "hash");
const generatedAt = Reflect.get(parsed, "generatedAt");
const compactSnapshot = CompactProviderSnapshotSchema.safeParse(
Reflect.get(parsed, "compactSnapshot"),
);
if (
version !== CACHE_VERSION ||
typeof hash !== "string" ||
typeof generatedAt !== "string" ||
!compactSnapshot.success
) {
return null;
}
return { version, hash, generatedAt, compactSnapshot: compactSnapshot.data };
}
export function createProviderSnapshotCache(
@@ -167,18 +173,12 @@ export function createProviderSnapshotCache(
}): Promise<void> {
const currentIndex = await ensureCacheIndex();
const key = cacheKey(input.serverId, input.cwd);
const stored = StoredProviderSnapshotSchema.safeParse({
const value = JSON.stringify({
version: CACHE_VERSION,
hash: input.hash,
generatedAt: input.generatedAt,
compactSnapshot: input.compactSnapshot,
});
if (!stored.success) {
await storage.removeItem(key);
await persistIndex(currentIndex.entries.filter((entry) => entry.key !== key));
return;
}
const value = JSON.stringify(stored.data);
} satisfies StoredProviderSnapshot);
const incomingBytes = storedBytes(key, value);
const canStoreIncoming = incomingBytes <= maxBytes;
const retainedEntries = currentIndex.entries.filter((entry) => entry.key !== key);
@@ -1,11 +1,9 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { BrowserAutomationBrowserIdSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
import { createJSONStorage, persist } from "zustand/middleware";
import {
applyBrowserPatch,
BrowserIndexStateSchema,
type BrowserIndexState,
type BrowserRecord,
type BrowserRecordPatch,
@@ -76,7 +74,7 @@ export const useBrowserStore = create<BrowserStoreState>()(
}),
{
name: "workspace-browser-store",
storage: createValidatedPersistStorage(AsyncStorage, BrowserIndexStateSchema),
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => sanitizeBrowsersForPersist(state),
merge: (persistedState, currentState) => ({
...currentState,
+34 -34
View File
@@ -1,5 +1,3 @@
import { z } from "zod";
export type BrowserViewport =
| { mode: "responsive" }
| { mode: "fixed"; width: number; height: number };
@@ -25,32 +23,6 @@ export interface BrowserIndexState {
browsersById: Record<string, BrowserRecord>;
}
const BrowserViewportSchema = z.discriminatedUnion("mode", [
z.strictObject({ mode: z.literal("responsive") }),
z.strictObject({
mode: z.literal("fixed"),
width: z.number().positive(),
height: z.number().positive(),
}),
]);
const BrowserRecordSchema = z.strictObject({
browserId: z.string(),
url: z.string(),
title: z.string(),
isLoading: z.boolean(),
canGoBack: z.boolean(),
canGoForward: z.boolean(),
faviconUrl: z.string().nullable(),
lastError: z.string().nullable(),
viewport: BrowserViewportSchema.optional().default(RESPONSIVE_BROWSER_VIEWPORT),
createdAt: z.number(),
});
export const BrowserIndexStateSchema: z.ZodType<BrowserIndexState> = z.strictObject({
browsersById: z.record(z.string(), BrowserRecordSchema),
});
export function createFixedBrowserViewport(width: number, height: number): BrowserViewport {
return {
mode: "fixed",
@@ -60,10 +32,19 @@ export function createFixedBrowserViewport(width: number, height: number): Brows
}
export function normalizeBrowserViewport(value: unknown): BrowserViewport {
const result = BrowserViewportSchema.safeParse(value);
return result.success && result.data.mode === "fixed"
? createFixedBrowserViewport(result.data.width, result.data.height)
: RESPONSIVE_BROWSER_VIEWPORT;
if (
value &&
typeof value === "object" &&
(value as { mode?: unknown }).mode === "fixed" &&
typeof (value as { width?: unknown }).width === "number" &&
typeof (value as { height?: unknown }).height === "number"
) {
return createFixedBrowserViewport(
(value as { width: number }).width,
(value as { height: number }).height,
);
}
return RESPONSIVE_BROWSER_VIEWPORT;
}
function browserViewportsEqual(left: BrowserViewport, right: BrowserViewport): boolean {
@@ -75,8 +56,27 @@ function browserViewportsEqual(left: BrowserViewport, right: BrowserViewport): b
}
export function normalizeBrowserIndexState(value: unknown): BrowserIndexState {
const result = BrowserIndexStateSchema.safeParse(value);
return result.success ? result.data : { browsersById: {} };
if (!value || typeof value !== "object") {
return { browsersById: {} };
}
const browsersById = (value as { browsersById?: unknown }).browsersById;
if (!browsersById || typeof browsersById !== "object") {
return { browsersById: {} };
}
return {
browsersById: Object.fromEntries(
Object.entries(browsersById).map(([browserId, record]) => {
const browser = record as BrowserRecord & { viewport?: unknown };
return [
browserId,
{
...browser,
viewport: normalizeBrowserViewport(browser.viewport),
} satisfies BrowserRecord,
];
}),
),
};
}
export function trimNonEmpty(value: string | null | undefined): string | null {
@@ -1,134 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildDiffFlatItems, sumHeightsBefore, type DiffFlatItem } from "./diff-flat-items";
import type { ParsedDiffFile } from "@/git/use-diff-query";
function createFile(path: string, additions = 1, deletions = 0): ParsedDiffFile {
return { path, isNew: false, isDeleted: false, additions, deletions, hunks: [] };
}
function summarize(items: DiffFlatItem[]): string[] {
return items.map((item) => {
if (item.type === "folder") {
return `${" ".repeat(item.depth)}[${item.displayName}]${item.collapsed ? " (collapsed)" : ""}`;
}
const base = item.file.path.split("/").pop();
return `${" ".repeat(item.depth)}${item.type === "body" ? "body:" : ""}${base}`;
});
}
describe("buildDiffFlatItems", () => {
const files = [createFile("src/app/a.ts"), createFile("src/app/nested/b.ts")];
it("emits a body and a sticky index for each expanded file", () => {
const { items, stickyHeaderIndices } = buildDiffFlatItems({
files: [createFile("a.ts"), createFile("b.ts")],
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(["a.ts"]),
});
// Root-level files (no folder): header, body (for expanded a.ts), header
expect(summarize(items)).toEqual(["a.ts", "body:a.ts", "b.ts"]);
// sticky points at the header (index 0), not the body
expect(stickyHeaderIndices).toEqual([0]);
expect(items[0].type).toBe("header");
});
it("emits the old flat list without folder rows or indentation", () => {
const { items, stickyHeaderIndices } = buildDiffFlatItems({
files,
viewMode: "flat",
collapsedFolders: new Set(),
expandedPaths: new Set(["src/app/a.ts"]),
});
expect(summarize(items)).toEqual(["a.ts", "body:a.ts", "b.ts"]);
expect(stickyHeaderIndices).toEqual([0]);
expect(items.every((item) => item.type !== "folder")).toBe(true);
});
it("groups files under compressed folder rows, all expanded by default", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(),
});
// dirs sort before files within a level: [nested] precedes a.ts
expect(summarize(items)).toEqual(["[src/app]", " [nested]", " b.ts", " a.ts"]);
});
it("collapsing a folder hides its descendants but keeps the row", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(["src/app/nested"]),
expandedPaths: new Set(),
});
expect(summarize(items)).toEqual(["[src/app]", " [nested] (collapsed)", " a.ts"]);
});
it("collapsing an ancestor hides everything below it", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(["src/app"]),
expandedPaths: new Set(),
});
expect(summarize(items)).toEqual(["[src/app] (collapsed)"]);
});
it("derives sticky indices file-headers-only from the post-collapse list", () => {
// dirs-first: [src/app], [nested], b.ts, a.ts. Expanding a.ts puts its
// header at index 3, with the body right after.
const { items, stickyHeaderIndices } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(["src/app/a.ts"]),
});
expect(summarize(items)).toEqual([
"[src/app]",
" [nested]",
" b.ts",
" a.ts",
" body:a.ts",
]);
expect(stickyHeaderIndices).toEqual([3]);
for (const idx of stickyHeaderIndices) {
expect(items[idx].type).toBe("header");
}
});
it("maps tree file rows back to their original files array index", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(),
});
// Tree order is b.ts (fileIndex 1) then a.ts (fileIndex 0)
const headers = items.filter((i) => i.type === "header");
expect(headers.map((h) => (h.type === "header" ? h.fileIndex : -1))).toEqual([1, 0]);
});
});
describe("sumHeightsBefore", () => {
const items = buildDiffFlatItems({
files: [createFile("src/a.ts"), createFile("src/b.ts")],
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(),
}).items; // [folder, a.ts, b.ts]
const heightFor = (item: DiffFlatItem) => (item.type === "folder" ? 10 : 20);
it("sums the heights of items before the index, counting folder rows", () => {
expect(sumHeightsBefore(items, 0, heightFor)).toBe(0);
expect(sumHeightsBefore(items, 1, heightFor)).toBe(10); // folder above a.ts
expect(sumHeightsBefore(items, 2, heightFor)).toBe(30); // folder + a.ts above b.ts
});
it("clamps an out-of-range index to the list length", () => {
expect(sumHeightsBefore(items, 999, heightFor)).toBe(50);
});
});
-121
View File
@@ -1,121 +0,0 @@
import type { ParsedDiffFile } from "@/git/use-diff-query";
import {
buildDiffTree,
compressSingleChildChains,
flattenDiffTree,
type DiffTreeDirNode,
} from "@/git/diff-tree";
// The row model for the Changes FlatList. `fileIndex` is the file's index within
// the (path-sorted) `files` array — a stable identity for testIDs/keys that is
// independent of folder rows and collapse state. `depth` drives indentation.
export type DiffFlatItem =
| {
type: "folder";
dirPath: string;
displayName: string;
depth: number;
collapsed: boolean;
additions: number;
deletions: number;
}
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean; depth: number }
| { type: "body"; file: ParsedDiffFile; fileIndex: number; depth: number };
export interface DiffFlatItemsResult {
items: DiffFlatItem[];
/** Indices into `items` of expanded file headers — folder rows are never sticky. */
stickyHeaderIndices: number[];
}
export interface BuildDiffFlatItemsInput {
files: ParsedDiffFile[];
viewMode: "flat" | "tree";
/** Full uncompressed directory paths that are collapsed (empty = all expanded). */
collapsedFolders: ReadonlySet<string>;
/** File paths whose diff body is expanded. */
expandedPaths: ReadonlySet<string>;
/**
* Pre-built compressed tree (from the same `files`). Pass it to avoid rebuilding
* the tree on every collapse toggle; omitted callers (tests) build it internally.
*/
tree?: DiffTreeDirNode;
}
/**
* Build the FlatList item model plus its sticky-header indices: files grouped
* under directory rows, single-child chains compressed, descendants of collapsed
* folders omitted.
*
* Sticky indices are derived from the FINAL (post-collapse) item list and only
* ever point at expanded FILE headers, so folder rows can't stack or collide in
* the sticky header stack.
*/
export function buildDiffFlatItems({
files,
viewMode,
collapsedFolders,
expandedPaths,
tree,
}: BuildDiffFlatItemsInput): DiffFlatItemsResult {
const items: DiffFlatItem[] = [];
const stickyHeaderIndices: number[] = [];
const pushFile = (file: ParsedDiffFile, fileIndex: number, depth: number): void => {
const isExpanded = expandedPaths.has(file.path);
items.push({ type: "header", file, fileIndex, isExpanded, depth });
if (isExpanded) {
stickyHeaderIndices.push(items.length - 1);
items.push({ type: "body", file, fileIndex, depth });
}
};
if (viewMode === "flat") {
for (const [fileIndex, file] of files.entries()) {
pushFile(file, fileIndex, 0);
}
return { items, stickyHeaderIndices };
}
const indexByPath = new Map(files.map((file, index) => [file.path, index]));
const compressedTree = tree ?? compressSingleChildChains(buildDiffTree(files));
const rows = flattenDiffTree(compressedTree, collapsedFolders);
for (const row of rows) {
if (row.kind === "folder") {
items.push({
type: "folder",
dirPath: row.dirPath,
displayName: row.displayName,
depth: row.depth,
collapsed: collapsedFolders.has(row.dirPath),
additions: row.additions,
deletions: row.deletions,
});
continue;
}
const fileIndex = indexByPath.get(row.file.path);
if (fileIndex === undefined) {
// Should never happen: the tree is built from the same `files` array.
continue;
}
pushFile(row.file, fileIndex, row.depth);
}
return { items, stickyHeaderIndices };
}
/** Cumulative height of every item before `index`. Single source of truth for
* FlatList's getItemLayout AND the collapse scroll-anchor math. */
export function sumHeightsBefore(
items: DiffFlatItem[],
index: number,
heightFor: (item: DiffFlatItem) => number,
): number {
let offset = 0;
const end = Math.min(index, items.length);
for (let i = 0; i < end; i++) {
offset += heightFor(items[i]);
}
return Math.max(0, offset);
}
+19 -3
View File
@@ -1,6 +1,7 @@
import { useCallback, useMemo } from "react";
import { View, Text, type LayoutChangeEvent, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Folder } from "lucide-react-native";
import { DiffStat } from "@/components/diff-stat";
import {
TreeChevron,
@@ -17,6 +18,9 @@ import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
import { FileActionsContextMenuContent } from "@/components/file-actions-menu";
import { isWeb } from "@/constants/platform";
const ThemedFolder = withUnistyles(Folder);
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
interface DiffFolderRowProps {
/** full uncompressed directory path — the collapse identity */
dirPath: string;
@@ -141,9 +145,15 @@ export function DiffFolderRow({
testID={testID ? `${testID}-toggle` : undefined}
>
<View style={leftStyle}>
<View style={styles.chevronSlot}>
<View style={styles.chevronOpticalOffset}>
<TreeChevron expanded={!collapsed} />
</View>
<View style={styles.folderIcon}>
<ThemedFolder
size={WORKSPACE_TREE_ICON_SIZE}
uniProps={foregroundMutedColorMapping}
/>
</View>
<Text style={styles.folderName} numberOfLines={1}>
{displayName}
</Text>
@@ -194,7 +204,13 @@ const styles = StyleSheet.create((theme: Theme) => ({
flex: 1,
minWidth: 0,
},
chevronSlot: {
chevronOpticalOffset: {
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,
alignItems: "center",
justifyContent: "center",
},
folderIcon: {
width: WORKSPACE_TREE_ICON_SIZE,
height: WORKSPACE_TREE_ICON_SIZE,
alignItems: "center",
@@ -0,0 +1,80 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { type LayoutChangeEvent, type StyleProp, type ViewStyle, View } from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
type SharedValue,
} from "react-native-reanimated";
import { DiffScroll } from "@/components/diff-scroll";
const FILL: ViewStyle = { flex: 1 };
const CLIP: ViewStyle = { flex: 1, overflow: "hidden" };
const DiffHorizontalOffsetContext = createContext<SharedValue<number> | null>(null);
export function DiffHorizontalSurface({
children,
contentWidth,
viewportWidth,
onViewportWidthChange,
}: {
children: ReactNode;
contentWidth: number;
viewportWidth: number;
onViewportWidthChange: (width: number) => void;
}) {
const offset = useSharedValue(0);
const pinnedViewportStyle = useAnimatedStyle(() => ({
transform: [{ translateX: offset.value }],
}));
const contentContainerStyle = useMemo<StyleProp<ViewStyle>>(
() => [{ flexGrow: 1 }, { minWidth: Math.max(viewportWidth, contentWidth) }],
[contentWidth, viewportWidth],
);
return (
<DiffHorizontalOffsetContext.Provider value={offset}>
<DiffScroll
offset={offset}
onScrollViewWidthChange={onViewportWidthChange}
style={FILL}
contentContainerStyle={contentContainerStyle}
testID="diff-horizontal-scroll"
>
<Animated.View style={[FILL, { width: viewportWidth }, pinnedViewportStyle]}>
{children}
</Animated.View>
</DiffScroll>
</DiffHorizontalOffsetContext.Provider>
);
}
export function DiffHorizontalContent({
children,
viewportStyle,
contentStyle,
onContentLayout,
testID,
}: {
children: ReactNode;
viewportStyle?: StyleProp<ViewStyle>;
contentStyle?: StyleProp<ViewStyle>;
onContentLayout?: (event: LayoutChangeEvent) => void;
testID?: string;
}) {
const offset = useContext(DiffHorizontalOffsetContext);
const translatedContentStyle = useAnimatedStyle(() => ({
transform: [{ translateX: -(offset?.value ?? 0) }],
}));
return (
<View style={[CLIP, viewportStyle]}>
<Animated.View
style={[contentStyle, translatedContentStyle]}
onLayout={onContentLayout}
testID={testID}
>
{children}
</Animated.View>
</View>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,286 @@
import { describe, expect, it } from "vitest";
import type { ParsedDiffFile } from "@/git/use-diff-query";
import {
buildDiffRowIndex,
createDiffRowCache,
DIFF_VIRTUALIZATION_CONFIG,
type DiffRowCache,
} from "./model";
function createFile(path: string, lineCount: number): ParsedDiffFile {
return {
path,
isNew: false,
isDeleted: false,
additions: lineCount,
deletions: 0,
hunks: [
{
oldStart: 1,
oldCount: 0,
newStart: 1,
newCount: lineCount,
lines: [
{ type: "header", content: `@@ -1,0 +1,${lineCount} @@` },
...Array.from({ length: lineCount }, (_, index) => ({
type: "add" as const,
content: `line ${index + 1}`,
})),
],
},
],
};
}
function buildIndex(files: ParsedDiffFile[], expandedPaths: string[]) {
return buildDiffRowIndex({
files,
viewMode: "flat",
collapsedFolders: new Set(),
expandedPaths: new Set(expandedPaths),
layout: "unified",
wrapLines: false,
lineHeight: 18,
viewportWidth: 400,
codeFontSize: 12,
headerHeightFor: () => 44,
folderHeight: 44,
statusHeight: 64,
measuredGroupHeights: new Map(),
geometryKey: "test",
reviewHeightFor: () => 0,
});
}
function containsReviewedLine(group: {
lines: Array<{ reviewTarget?: { lineNumber?: number } | null }>;
}): boolean {
return group.lines.some((line) => line.reviewTarget?.lineNumber === 10);
}
function summarizeTree(items: ReturnType<typeof buildIndex>["items"]): string[] {
return items.map((item) => {
if (item.type === "folder") {
return `${" ".repeat(item.depth)}[${item.displayName}]${item.collapsed ? " (collapsed)" : ""}`;
}
const base = item.file.path.split("/").pop();
const isGroup = item.type === "unified_group" || item.type === "split_group";
return `${" ".repeat(item.depth)}${isGroup ? "group:" : ""}${base}`;
});
}
describe("buildDiffRowIndex", () => {
it("turns a large expanded file into bounded native-list cells", () => {
const index = buildIndex([createFile("large.ts", 1_200)], ["large.ts"]);
const groups = index.items.filter((item) => item.type === "unified_group");
expect(groups.length).toBeGreaterThan(1);
expect(Math.max(...groups.map((group) => group.lines.length))).toBeLessThanOrEqual(
DIFF_VIRTUALIZATION_CONFIG.groupRowLimit,
);
expect(groups.reduce((count, group) => count + group.lines.length, 0)).toBe(1_201);
expect(index.stickyHeaderIndices).toEqual([0]);
});
it("counts wide glyphs and tabs conservatively for horizontal scroll extent", () => {
for (const [path, content] of [
["cjk.ts", "界".repeat(40)],
["emoji.ts", "😀".repeat(40)],
["tabs.ts", "\t".repeat(10)],
]) {
const file = createFile(path, 1);
file.hunks[0].lines[1].content = content;
const index = buildIndex([file], [file.path]);
const group = index.items.find((item) => item.type === "unified_group");
expect(group?.contentColumns).toBe(80);
}
});
it("precomputes exact offsets and preserves total file height", () => {
const index = buildIndex(
[createFile("first.ts", 65), createFile("second.ts", 1)],
["first.ts"],
);
const firstGroups = index.items.filter(
(item) => item.type === "unified_group" && item.file.path === "first.ts",
);
const secondHeaderIndex = index.items.findIndex(
(item) => item.type === "header" && item.file.path === "second.ts",
);
const expectedBodyHeight = 66 * 18 + 2;
expect(firstGroups).toHaveLength(5);
expect(index.offsetAt(secondHeaderIndex)).toBe(44 + expectedBodyHeight);
expect(index.offsetOfFile("second.ts")).toBe(44 + expectedBodyHeight);
expect(index.totalHeight).toBe(44 + expectedBodyHeight + 44);
});
it("keeps a review row whole and shifts every later offset by its height", () => {
const file = createFile("review.ts", 40);
const baseline = buildIndex([file], [file.path]);
const reviewed = buildDiffRowIndex({
files: [file],
viewMode: "flat",
collapsedFolders: new Set(),
expandedPaths: new Set([file.path]),
layout: "unified",
wrapLines: false,
lineHeight: 18,
viewportWidth: 400,
codeFontSize: 12,
headerHeightFor: () => 44,
folderHeight: 44,
statusHeight: 64,
measuredGroupHeights: new Map(),
geometryKey: "test",
reviewHeightFor: (target) => (target?.lineNumber === 10 ? 132 : 0),
});
expect(reviewed.totalHeight - baseline.totalHeight).toBe(132);
const reviewGroup = reviewed.items
.filter((item) => item.type === "unified_group")
.find(containsReviewedLine);
expect(reviewGroup).toBeDefined();
expect(reviewed.heightAt(reviewed.items.indexOf(reviewGroup!))).toBeGreaterThanOrEqual(150);
});
it("preserves compressed tree order, indentation, and collapse state", () => {
const files = [createFile("src/app/a.ts", 1), createFile("src/app/nested/b.ts", 1)];
const index = buildDiffRowIndex({
files,
viewMode: "tree",
collapsedFolders: new Set(["src/app/nested"]),
expandedPaths: new Set(["src/app/a.ts"]),
layout: "unified",
wrapLines: false,
lineHeight: 18,
viewportWidth: 400,
codeFontSize: 12,
headerHeightFor: () => 44,
folderHeight: 44,
statusHeight: 64,
measuredGroupHeights: new Map(),
geometryKey: "tree",
reviewHeightFor: () => 0,
});
expect(summarizeTree(index.items)).toEqual([
"[src/app]",
" [nested] (collapsed)",
" a.ts",
" group:a.ts",
]);
expect(index.stickyHeaderIndices).toEqual([2]);
});
it("keeps split groups bounded and sticky indices on headers", () => {
const file = createFile("split.ts", 100);
const index = buildDiffRowIndex({
files: [file],
viewMode: "flat",
collapsedFolders: new Set(),
expandedPaths: new Set([file.path]),
layout: "split",
wrapLines: false,
lineHeight: 18,
viewportWidth: 800,
codeFontSize: 12,
headerHeightFor: () => 44,
folderHeight: 44,
statusHeight: 64,
measuredGroupHeights: new Map(),
geometryKey: "split",
reviewHeightFor: () => 0,
});
const groups = index.items.filter((item) => item.type === "split_group");
expect(Math.max(...groups.map((group) => group.rows.length))).toBeLessThanOrEqual(
DIFF_VIRTUALIZATION_CONFIG.groupRowLimit,
);
expect(index.items[index.stickyHeaderIndices[0]].type).toBe("header");
});
it("updates measured group geometry without deriving any file rows again", () => {
const delegate = createDiffRowCache();
let derivations = 0;
const rowCache: DiffRowCache = {
unifiedFor(file) {
derivations += 1;
return delegate.unifiedFor(file);
},
splitFor(file) {
derivations += 1;
return delegate.splitFor(file);
},
};
const files = [createFile("first.ts", 100), createFile("second.ts", 100)];
const index = buildDiffRowIndex({
files,
viewMode: "flat",
collapsedFolders: new Set(),
expandedPaths: new Set(files.map((file) => file.path)),
layout: "unified",
wrapLines: true,
lineHeight: 18,
viewportWidth: 400,
codeFontSize: 12,
headerHeightFor: () => 44,
folderHeight: 44,
statusHeight: 64,
measuredGroupHeights: new Map(),
geometryKey: "wrap",
reviewHeightFor: () => 0,
rowCache,
});
const group = index.items.find((item) => item.type === "unified_group");
expect(group).toBeDefined();
expect(derivations).toBe(2);
const groupIndex = index.indexOfGroup(group!.key);
const laterOffset = index.offsetAt(groupIndex + 1);
const update = index.updateHeight(group!.key, index.heightAt(groupIndex) + 20);
expect(update?.delta).toBe(20);
expect(index.offsetAt(groupIndex + 1)).toBe(laterOffset + 20);
expect(derivations).toBe(2);
});
it("uses the key's rounded viewport width for wrapped group membership", () => {
const file = createFile("wrapped.ts", 40);
for (const line of file.hunks[0].lines) {
if (line.type !== "header") {
line.content = "x".repeat(88);
}
}
const buildWrapped = (viewportWidth: number) =>
buildDiffRowIndex({
files: [file],
viewMode: "flat",
collapsedFolders: new Set(),
expandedPaths: new Set([file.path]),
layout: "unified",
wrapLines: true,
lineHeight: 18,
viewportWidth,
codeFontSize: 12,
headerHeightFor: () => 44,
folderHeight: 44,
statusHeight: 64,
measuredGroupHeights: new Map(),
geometryKey: `wrap:${Math.round(viewportWidth)}`,
reviewHeightFor: () => 0,
});
const summarizeGroups = (viewportWidth: number) =>
buildWrapped(viewportWidth)
.items.filter((item) => item.type === "unified_group")
.map((group) => ({
key: group.key,
startRowIndex: group.startRowIndex,
lineCount: group.lines.length,
}));
expect(summarizeGroups(399.2)).toEqual(summarizeGroups(399.49));
});
});
+422
View File
@@ -0,0 +1,422 @@
import type { ParsedDiffFile } from "@/git/use-diff-query";
import {
buildDiffTree,
compressSingleChildChains,
flattenDiffTree,
type DiffTreeDirNode,
} from "@/git/diff-tree";
import {
buildSplitDiffRows,
buildUnifiedDiffLines,
type ReviewableDiffTarget,
type SplitDiffRow,
type UnifiedDiffDisplayLine,
} from "@/utils/diff-layout";
// Tune cell size and the outer list window together. On the 1,200-row fixture, the exact
// three-fling repro filled immediately with both 32/720 + window 21 and this lower-cost pair
// on an iPhone 17 Pro simulator. Simulator frame percentiles are unavailable in xctrace.
export const DIFF_VIRTUALIZATION_CONFIG = {
groupRowLimit: 16,
groupHeightLimit: 360,
initialNumToRender: 8,
maxToRenderPerBatch: 8,
updateCellsBatchingPeriod: 16,
windowSize: 10,
} as const;
interface FolderItem {
type: "folder";
dirPath: string;
displayName: string;
depth: number;
collapsed: boolean;
additions: number;
deletions: number;
}
interface HeaderItem {
type: "header";
file: ParsedDiffFile;
fileIndex: number;
isExpanded: boolean;
depth: number;
}
interface StatusItem {
type: "status";
file: ParsedDiffFile;
fileIndex: number;
depth: number;
}
interface RowGroupBase {
file: ParsedDiffFile;
fileIndex: number;
depth: number;
groupIndex: number;
startRowIndex: number;
isFirst: boolean;
isLast: boolean;
contentColumns: number;
key: string;
}
export interface UnifiedRowGroupItem extends RowGroupBase {
type: "unified_group";
lines: UnifiedDiffDisplayLine[];
}
export interface SplitRowGroupItem extends RowGroupBase {
type: "split_group";
rows: SplitDiffRow[];
}
export type DiffListItem =
| FolderItem
| HeaderItem
| StatusItem
| UnifiedRowGroupItem
| SplitRowGroupItem;
export interface DiffRowIndex {
items: DiffListItem[];
stickyHeaderIndices: number[];
heightAt: (index: number) => number;
offsetAt: (index: number) => number;
offsetOfFile: (path: string) => number;
indexOfFile: (path: string) => number;
indexOfGroup: (key: string) => number;
updateHeight: (
key: string,
height: number,
) => { delta: number; index: number; previousHeight: number } | null;
totalHeight: number;
}
export interface DiffRowCache {
unifiedFor: (file: ParsedDiffFile) => UnifiedDiffDisplayLine[];
splitFor: (file: ParsedDiffFile) => SplitDiffRow[];
}
export function createDiffRowCache(): DiffRowCache {
const unified = new WeakMap<ParsedDiffFile, UnifiedDiffDisplayLine[]>();
const split = new WeakMap<ParsedDiffFile, SplitDiffRow[]>();
return {
unifiedFor(file) {
const cached = unified.get(file);
if (cached) return cached;
const lines = buildUnifiedDiffLines(file);
unified.set(file, lines);
return lines;
},
splitFor(file) {
const cached = split.get(file);
if (cached) return cached;
const rows = buildSplitDiffRows(file);
split.set(file, rows);
return rows;
},
};
}
const defaultRowCache = createDiffRowCache();
export interface BuildDiffRowIndexInput {
files: ParsedDiffFile[];
viewMode: "flat" | "tree";
collapsedFolders: ReadonlySet<string>;
expandedPaths: ReadonlySet<string>;
layout: "unified" | "split";
wrapLines: boolean;
lineHeight: number;
viewportWidth: number;
codeFontSize: number;
headerHeightFor: (file: ParsedDiffFile) => number;
folderHeight: number;
statusHeight: number;
measuredGroupHeights: ReadonlyMap<string, number>;
geometryKey: string;
reviewHeightFor: (target: ReviewableDiffTarget | null | undefined) => number;
rowCache?: DiffRowCache;
tree?: DiffTreeDirNode;
}
interface PendingGroup<Row> {
rows: Row[];
startRowIndex: number;
height: number;
}
const FULL_WIDTH_CODE_POINT_RANGES = [
[0x1100, 0x115f],
[0x2329, 0x232a],
[0x2e80, 0x303e],
[0x3040, 0x3247],
[0x3250, 0x4dbf],
[0x4e00, 0xa4c6],
[0xa960, 0xa97c],
[0xac00, 0xd7a3],
[0xf900, 0xfaff],
[0xfe10, 0xfe19],
[0xfe30, 0xfe6b],
[0xff01, 0xff60],
[0xffe0, 0xffe6],
[0x1b000, 0x1b001],
[0x1f200, 0x1f251],
[0x1f300, 0x1faff],
[0x20000, 0x3fffd],
] as const;
function isFullWidthCodePoint(codePoint: number): boolean {
return FULL_WIDTH_CODE_POINT_RANGES.some(
([start, end]) => codePoint >= start && codePoint <= end,
);
}
function displayColumns(content: string): number {
let columns = 0;
for (const character of content) {
if (character === "\t") {
columns += 8 - (columns % 8);
continue;
}
columns += isFullWidthCodePoint(character.codePointAt(0) ?? 0) ? 2 : 1;
}
return columns;
}
function estimatedVisualLines(content: string, columns: number): number {
return Math.max(1, Math.ceil(displayColumns(content) / columns));
}
function groupRows<Row>(rows: Row[], heightFor: (row: Row) => number): PendingGroup<Row>[] {
const groups: PendingGroup<Row>[] = [];
let current: PendingGroup<Row> = { rows: [], startRowIndex: 0, height: 0 };
for (const [rowIndex, row] of rows.entries()) {
const rowHeight = heightFor(row);
const reachedRowLimit = current.rows.length >= DIFF_VIRTUALIZATION_CONFIG.groupRowLimit;
const reachedHeightLimit =
current.rows.length > 0 &&
current.height + rowHeight > DIFF_VIRTUALIZATION_CONFIG.groupHeightLimit;
if (reachedRowLimit || reachedHeightLimit) {
groups.push(current);
current = { rows: [], startRowIndex: rowIndex, height: 0 };
}
current.rows.push(row);
current.height += rowHeight;
}
if (current.rows.length > 0) {
groups.push(current);
}
return groups;
}
function contentColumnsForUnified(lines: UnifiedDiffDisplayLine[]): number {
return lines.reduce((longest, line) => Math.max(longest, displayColumns(line.line.content)), 0);
}
function contentColumnsForSplit(rows: SplitDiffRow[]): number {
return rows.reduce((longest, row) => {
if (row.kind === "header") {
return Math.max(longest, displayColumns(row.content));
}
return Math.max(
longest,
displayColumns(row.left?.content ?? ""),
displayColumns(row.right?.content ?? ""),
);
}, 0);
}
function availableTextColumns(input: BuildDiffRowIndexInput): number {
const viewportWidth = Math.round(input.viewportWidth);
const columnWidth = input.layout === "split" ? viewportWidth / 2 : viewportWidth;
const characterWidth = input.codeFontSize * 0.62;
return Math.max(8, Math.floor((columnWidth - 72) / characterWidth));
}
export function buildDiffRowIndex(input: BuildDiffRowIndexInput): DiffRowIndex {
const items: DiffListItem[] = [];
const heights: number[] = [];
const stickyHeaderIndices: number[] = [];
const fileIndexByPath = new Map<string, number>();
const groupIndexByKey = new Map<string, number>();
const availableColumns = availableTextColumns(input);
const rowCache = input.rowCache ?? defaultRowCache;
function push(item: DiffListItem, height: number): void {
if (item.type === "unified_group" || item.type === "split_group") {
groupIndexByKey.set(item.key, items.length);
}
items.push(item);
heights.push(height);
}
function pushUnifiedGroups(file: ParsedDiffFile, fileIndex: number, depth: number): void {
const lines = rowCache.unifiedFor(file);
const contentColumns = contentColumnsForUnified(lines);
const groups = groupRows(lines, (line) => {
const visualLines = input.wrapLines
? estimatedVisualLines(line.line.content, availableColumns)
: 1;
return visualLines * input.lineHeight + input.reviewHeightFor(line.reviewTarget);
});
for (const [groupIndex, group] of groups.entries()) {
const isFirst = groupIndex === 0;
const isLast = groupIndex === groups.length - 1;
const key = `${input.geometryKey}:${file.path}:unified:${group.startRowIndex}`;
const estimatedHeight = group.height + (isFirst ? 1 : 0) + (isLast ? 1 : 0);
push(
{
type: "unified_group",
file,
fileIndex,
depth,
groupIndex,
startRowIndex: group.startRowIndex,
isFirst,
isLast,
contentColumns,
key,
lines: group.rows,
},
input.measuredGroupHeights.get(key) ?? estimatedHeight,
);
}
}
function pushSplitGroups(file: ParsedDiffFile, fileIndex: number, depth: number): void {
const rows = rowCache.splitFor(file);
const contentColumns = contentColumnsForSplit(rows);
const groups = groupRows(rows, (row) => {
if (row.kind === "header") {
const visualLines = input.wrapLines
? estimatedVisualLines(row.content, availableColumns)
: 1;
return visualLines * input.lineHeight;
}
const visualLines = input.wrapLines
? Math.max(
estimatedVisualLines(row.left?.content ?? "", availableColumns),
estimatedVisualLines(row.right?.content ?? "", availableColumns),
)
: 1;
const reviewHeight = Math.max(
input.reviewHeightFor(row.left?.reviewTarget),
input.reviewHeightFor(row.right?.reviewTarget),
);
return visualLines * input.lineHeight + reviewHeight;
});
for (const [groupIndex, group] of groups.entries()) {
const isFirst = groupIndex === 0;
const isLast = groupIndex === groups.length - 1;
const key = `${input.geometryKey}:${file.path}:split:${group.startRowIndex}`;
const estimatedHeight = group.height + (isFirst ? 1 : 0) + (isLast ? 1 : 0);
push(
{
type: "split_group",
file,
fileIndex,
depth,
groupIndex,
startRowIndex: group.startRowIndex,
isFirst,
isLast,
contentColumns,
key,
rows: group.rows,
},
input.measuredGroupHeights.get(key) ?? estimatedHeight,
);
}
}
function pushFile(file: ParsedDiffFile, fileIndex: number, depth: number): void {
const isExpanded = input.expandedPaths.has(file.path);
const headerIndex = items.length;
fileIndexByPath.set(file.path, headerIndex);
push({ type: "header", file, fileIndex, isExpanded, depth }, input.headerHeightFor(file));
if (!isExpanded) {
return;
}
stickyHeaderIndices.push(headerIndex);
if (file.status === "binary" || file.status === "too_large") {
push({ type: "status", file, fileIndex, depth }, input.statusHeight);
return;
}
if (input.layout === "split") {
pushSplitGroups(file, fileIndex, depth);
return;
}
pushUnifiedGroups(file, fileIndex, depth);
}
if (input.viewMode === "flat") {
for (const [fileIndex, file] of input.files.entries()) {
pushFile(file, fileIndex, 0);
}
} else {
const indexByPath = new Map(input.files.map((file, index) => [file.path, index]));
const tree = input.tree ?? compressSingleChildChains(buildDiffTree(input.files));
for (const row of flattenDiffTree(tree, input.collapsedFolders)) {
if (row.kind === "folder") {
push(
{
type: "folder",
dirPath: row.dirPath,
displayName: row.displayName,
depth: row.depth,
collapsed: input.collapsedFolders.has(row.dirPath),
additions: row.additions,
deletions: row.deletions,
},
input.folderHeight,
);
continue;
}
const fileIndex = indexByPath.get(row.file.path);
if (fileIndex !== undefined) {
pushFile(row.file, fileIndex, row.depth);
}
}
}
const offsets = Array.from({ length: items.length + 1 }, () => 0);
for (let index = 0; index < items.length; index += 1) {
offsets[index + 1] = offsets[index] + heights[index];
}
return {
items,
stickyHeaderIndices,
heightAt: (index) => heights[index] ?? 0,
offsetAt: (index) => offsets[Math.max(0, Math.min(index, items.length))],
offsetOfFile: (path) => offsets[fileIndexByPath.get(path) ?? 0],
indexOfFile: (path) => fileIndexByPath.get(path) ?? -1,
indexOfGroup: (key) => groupIndexByKey.get(key) ?? -1,
updateHeight: (key, height) => {
const index = groupIndexByKey.get(key);
if (index === undefined || !Number.isFinite(height) || height < 0) {
return null;
}
const previousHeight = heights[index];
const delta = height - previousHeight;
if (delta === 0) {
return { delta, index, previousHeight };
}
heights[index] = height;
for (let offsetIndex = index + 1; offsetIndex < offsets.length; offsetIndex += 1) {
offsets[offsetIndex] += delta;
}
return { delta, index, previousHeight };
},
get totalHeight() {
return offsets[items.length];
},
};
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { boundRetainedRangeOnDataGrowth } from "@react-native/virtualized-lists/Lists/boundRetainedRangeOnDataGrowth";
describe("bounded VirtualizedList data growth", () => {
it("limits the carried range when middle insertion grows the list", () => {
expect(boundRetainedRangeOnDataGrowth({ first: 0, last: 39 }, 40, 115, 16)).toEqual({
first: 0,
last: 15,
});
});
it("bounds from the current scrolled range instead of jumping to the start", () => {
expect(boundRetainedRangeOnDataGrowth({ first: 84, last: 123 }, 160, 235, 16)).toEqual({
first: 84,
last: 99,
});
});
it("does not alter the range when data shrinks or the carried range is already bounded", () => {
const shortRange = { first: 12, last: 18 };
expect(boundRetainedRangeOnDataGrowth(shortRange, 40, 32, 16)).toBe(shortRange);
expect(boundRetainedRangeOnDataGrowth(shortRange, 40, 115, 16)).toEqual(shortRange);
});
});
+2 -4
View File
@@ -2,7 +2,6 @@ import { useState, useCallback, useEffect, useMemo, type ReactElement } from "re
import { Info } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { z } from "zod";
import { useTranslation } from "react-i18next";
import type { Theme } from "@/styles/theme";
import { getForgePresentation, type Forge } from "@/git/forge";
@@ -30,7 +29,6 @@ import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-ar
import { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
import { readValidatedString } from "@/storage/validated-storage";
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
@@ -363,10 +361,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
}
let isActive = true;
setShipDefault("pr");
readValidatedString(AsyncStorage, shipDefaultStorageKey, z.enum(["pr", "merge"]))
AsyncStorage.getItem(shipDefaultStorageKey)
.then((value) => {
if (!isActive) return;
if (value) {
if (value === "pr" || value === "merge") {
setShipDefault(value);
return;
}
+37 -67
View File
@@ -14,7 +14,6 @@ import {
} from "@/provider-selection/provider-selection";
import { filterSelectableModels } from "@/provider-selection/model-catalog";
import { OptimisticFormPreferences } from "@/create-agent-preferences/optimistic-preferences";
import { applyAgentProfilePreferences } from "@/create-agent-preferences/preferences";
import { useProvidersSnapshot } from "./use-providers-snapshot";
import {
useFormPreferences,
@@ -38,7 +37,6 @@ import {
type FormState,
type ProviderModelsByProvider,
} from "@/provider-selection/resolve-agent-form";
import type { MaterializedAgentProfile } from "@/agent-profiles";
export type { FormInitialValues } from "@/provider-selection/resolve-agent-form";
@@ -56,6 +54,7 @@ export interface UseAgentFormStateResult {
setSelectedServerId: (value: string | null) => void;
setSelectedServerIdFromUser: (value: string | null) => void;
selectedProvider: AgentProvider | null;
setProviderFromUser: (provider: AgentProvider) => void;
selectedMode: string;
setModeFromUser: (modeId: string) => void;
selectedModel: string;
@@ -81,7 +80,6 @@ export interface UseAgentFormStateResult {
refreshProviderModels: (provider?: AgentProvider) => void;
refetchProviderModelsIfStale: () => void;
setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void;
applyProfileFromUser: (profile: MaterializedAgentProfile) => void;
clearProviderSelectionFromUser: () => void;
workingDirIsEmpty: boolean;
persistFormPreferences: () => Promise<void>;
@@ -256,6 +254,11 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
}),
);
const reducerStateRef = useRef({ form: formState, userModified });
useEffect(() => {
reducerStateRef.current = { form: formState, userModified };
}, [formState, userModified]);
useEffect(() => {
if (!isVisible) {
dispatch({ type: "RESET" });
@@ -420,6 +423,27 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
dispatch({ type: "SET_SERVER_ID_FROM_USER", value });
}, []);
const setProviderFromUser = useCallback(
(provider: AgentProvider) => {
if (!selectableProviderDefinitionMap.has(provider)) {
return;
}
const providerModels = allProviderModels.get(provider) ?? null;
const providerDef = selectableProviderDefinitionMap.get(provider);
const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider];
dispatch({
type: "SET_PROVIDER_FROM_USER",
provider,
providerModels,
providerDef,
providerPrefs,
});
void updateCurrentPreferences({ provider });
},
[allProviderModels, selectableProviderDefinitionMap, updateCurrentPreferences],
);
const setProviderAndModelFromUser = useCallback(
(provider: AgentProvider, modelId: string) => {
if (!selectableProviderDefinitionMap.has(provider)) {
@@ -456,64 +480,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
dispatch({ type: "CLEAR_PROVIDER_SELECTION_FROM_USER" });
}, []);
const applyProfileFromUser = useCallback(
(profile: MaterializedAgentProfile) => {
const provider = profile.provider as AgentProvider;
if (!selectableProviderDefinitionMap.has(provider)) {
return;
}
const previousProvider = formState.provider;
const providerDef = selectableProviderDefinitionMap.get(provider);
const providerModels = allProviderModels.get(provider) ?? null;
const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider];
const action = {
type: "APPLY_PROFILE_FROM_USER" as const,
provider,
modelId: profile.modelId,
modeId: profile.modeId,
thinkingOptionId: profile.thinkingOptionId,
providerDef,
providerModels,
providerPrefs,
};
const nextState = resolveAgentForm({ form: formState, userModified, resolution }, action);
const previousProviderModeIds = previousProvider
? (providerDefinitionMap.get(previousProvider)?.modes.map((mode) => mode.id) ?? [])
: [];
dispatch(action);
void updateCurrentPreferences((current) => {
const { model, modeId, thinkingOptionId } = nextState.form;
return applyAgentProfilePreferences({
preferences: current,
previousProvider,
previousProviderModeIds,
provider,
modelId: model,
modeId,
thinkingOptionId,
featureValues: profile.featureValues,
});
}).catch((error) => {
console.warn("[useAgentFormState] persist profile preference failed", error);
});
},
[
allProviderModels,
formState,
providerDefinitionMap,
resolution,
selectableProviderDefinitionMap,
updateCurrentPreferences,
userModified,
],
);
const setModeFromUser = useCallback(
(modeId: string) => {
dispatch({ type: "SET_MODE_FROM_USER", modeId });
const provider = formState.provider;
const provider = reducerStateRef.current.form.provider;
if (provider) {
void updateCurrentPreferences((current) =>
mergeSelectedComposerPreferences({
@@ -526,12 +496,12 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
);
}
},
[formState.provider, updateCurrentPreferences],
[updateCurrentPreferences],
);
const setModelFromUser = useCallback(
(modelId: string) => {
const provider = formState.provider;
const provider = reducerStateRef.current.form.provider;
const providerPrefs = provider
? preferenceOverlayRef.current.current().providerPreferences?.[provider]
: undefined;
@@ -555,13 +525,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
);
}
},
[availableModels, formState.provider, updateCurrentPreferences],
[availableModels, updateCurrentPreferences],
);
const setThinkingOptionFromUser = useCallback(
(thinkingOptionId: string) => {
dispatch({ type: "SET_THINKING_OPTION_FROM_USER", thinkingOptionId });
const { provider, model: modelId } = formState;
const { provider, model: modelId } = reducerStateRef.current.form;
if (provider && modelId) {
void updateCurrentPreferences((current) =>
mergeSelectedComposerPreferences({
@@ -576,7 +546,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
);
}
},
[formState, updateCurrentPreferences],
[updateCurrentPreferences],
);
const setWorkingDir = useCallback((value: string) => {
@@ -599,8 +569,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
);
const refetchProviderModelsIfStale = useCallback(() => {
refetchSnapshotIfStale(formState.provider);
}, [formState.provider, refetchSnapshotIfStale]);
refetchSnapshotIfStale(reducerStateRef.current.form.provider);
}, [refetchSnapshotIfStale]);
const persistFormPreferences = useCallback(async () => {
if (!formState.provider) {
@@ -634,6 +604,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
setSelectedServerId,
setSelectedServerIdFromUser,
selectedProvider: formState.provider,
setProviderFromUser,
selectedMode: formState.modeId,
setModeFromUser,
selectedModel: formState.model,
@@ -659,7 +630,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
refreshProviderModels,
refetchProviderModelsIfStale,
setProviderAndModelFromUser,
applyProfileFromUser,
clearProviderSelectionFromUser,
workingDirIsEmpty,
persistFormPreferences,
@@ -673,6 +643,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
formState.workingDir,
setSelectedServerId,
setSelectedServerIdFromUser,
setProviderFromUser,
setModeFromUser,
setModelFromUser,
setThinkingOptionFromUser,
@@ -694,7 +665,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
refreshProviderModels,
refetchProviderModelsIfStale,
setProviderAndModelFromUser,
applyProfileFromUser,
clearProviderSelectionFromUser,
workingDirIsEmpty,
persistFormPreferences,
@@ -16,8 +16,5 @@ export function createInMemoryKeyValueStorage(
async setItem(key, value) {
entries.set(key, value);
},
async removeItem(key) {
entries.delete(key);
},
};
}
@@ -1,12 +1,11 @@
import type { QueryClient } from "@tanstack/react-query";
import { z } from "zod";
import { readValidatedJson, readValidatedString } from "@/storage/validated-storage";
export const CHANGES_PREFERENCES_STORAGE_KEY = "@paseo:changes-preferences";
export const LEGACY_WRAP_LINES_STORAGE_KEY = "diff-wrap-lines";
export const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"];
const changesPreferencesSchema = z.strictObject({
const changesPreferencesSchema = z.object({
layout: z.enum(["unified", "split"]).optional(),
viewMode: z.enum(["flat", "tree"]).optional(),
wrapLines: z.boolean().optional(),
@@ -33,28 +32,28 @@ export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = {
export interface KeyValueStorage {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
}
async function loadLegacyWrapLinesPreference(storage: KeyValueStorage): Promise<boolean | null> {
const legacyValue = await readValidatedString(
storage,
LEGACY_WRAP_LINES_STORAGE_KEY,
z.enum(["true", "false"]),
);
return legacyValue === null ? null : legacyValue === "true";
const legacyValue = await storage.getItem(LEGACY_WRAP_LINES_STORAGE_KEY);
if (legacyValue === "true") {
return true;
}
if (legacyValue === "false") {
return false;
}
return null;
}
export async function loadChangesPreferencesFromStorage(
storage: KeyValueStorage,
): Promise<ChangesPreferences> {
const stored = await readValidatedJson(
storage,
CHANGES_PREFERENCES_STORAGE_KEY,
changesPreferencesSchema,
);
const stored = await storage.getItem(CHANGES_PREFERENCES_STORAGE_KEY);
if (stored) {
return { ...DEFAULT_CHANGES_PREFERENCES, ...stored };
const parsed = changesPreferencesSchema.safeParse(JSON.parse(stored));
if (parsed.success) {
return { ...DEFAULT_CHANGES_PREFERENCES, ...parsed.data };
}
}
const legacyWrapLines = await loadLegacyWrapLinesPreference(storage);
@@ -145,15 +145,10 @@ export function useDraftAgentFeatures(input: {
[provider, updatePreferences],
);
const applyProfileFeatureValues = useCallback((values: Record<string, unknown>) => {
setLocalFeatureValues(values);
}, []);
return {
features,
featureValues: effectiveFeatureValues,
isLoading: featuresQuery.isLoading,
setFeatureValue,
applyProfileFeatureValues,
};
}
@@ -1,18 +1,15 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import type { ShortcutOverrides } from "@/keyboard/keyboard-shortcuts";
import {
createShortcutOverrideStore,
type ShortcutOverrideStore,
} from "@/keyboard/shortcut-override-store";
import { readValidatedJson } from "@/storage/validated-storage";
const STORAGE_KEY = "@paseo:keyboard-shortcut-overrides";
const QUERY_KEY = ["keyboard-shortcut-overrides"];
const EMPTY_OVERRIDES: ShortcutOverrides = {};
const ShortcutOverridesSchema = z.record(z.string(), z.string().nullable());
export interface UseKeyboardShortcutOverridesReturn {
overrides: ShortcutOverrides;
@@ -79,10 +76,10 @@ function getStore(queryClient: QueryClient): ShortcutOverrideStore {
async function loadOverridesFromStorage(): Promise<ShortcutOverrides> {
try {
return (
(await readValidatedJson(AsyncStorage, STORAGE_KEY, ShortcutOverridesSchema)) ??
EMPTY_OVERRIDES
);
const stored = await AsyncStorage.getItem(STORAGE_KEY);
if (stored) {
return JSON.parse(stored) as ShortcutOverrides;
}
} catch (err) {
console.error("[KeyboardShortcutOverrides] Failed to load overrides:", err);
}
@@ -1,8 +1,6 @@
import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import { readValidatedString } from "@/storage/validated-storage";
type EditorTargetId = string;
@@ -10,7 +8,11 @@ const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor";
const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"];
async function loadPreferredEditor(): Promise<EditorTargetId | null> {
return readValidatedString(AsyncStorage, PREFERRED_EDITOR_STORAGE_KEY, z.string().trim().min(1));
const stored = await AsyncStorage.getItem(PREFERRED_EDITOR_STORAGE_KEY);
if (!stored) {
return null;
}
return stored.trim() || null;
}
export function resolvePreferredEditorId(
@@ -17,9 +17,6 @@ export function createInMemoryKeyValueStorage(
async setItem(key, value) {
entries.set(key, value);
},
async removeItem(key) {
entries.delete(key);
},
};
}
@@ -15,7 +15,6 @@ import {
type SettingsDeps,
} from "./storage";
import { createFakeDesktopBridge, createInMemoryKeyValueStorage } from "./fakes";
import { THEME_OPTIONS } from "@/styles/theme";
const LEGACY_SETTINGS_KEY = "@paseo:settings";
@@ -43,18 +42,6 @@ describe("loadAppSettingsFromStorage", () => {
expect(result.theme).toBe("auto");
});
it.each(THEME_OPTIONS)("loads the persisted $name theme", async ({ name }) => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ theme: name }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.theme).toBe(name);
});
it("seeds storage with the client defaults when nothing is persisted", async () => {
const deps = makeDeps();
@@ -395,14 +382,14 @@ describe("appearance settings", () => {
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview");
});
it("clears settings with an unrecognized tool call detail level", async () => {
it("maps an unrecognized tool call detail level to overview", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "unknown" }),
}),
});
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("detailed");
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview");
});
it("migrates a switched-off checks row item to the hidden checks display", async () => {
+27 -63
View File
@@ -14,8 +14,6 @@ import {
type SidebarRowItems,
} from "@/components/sidebar/display-preferences/row-items";
import { THEME_OPTIONS, type ThemePreference } from "@/styles/theme";
import { z } from "zod";
import { readValidatedJson } from "@/storage/validated-storage";
export const APP_SETTINGS_KEY = "@paseo:app-settings";
export const APP_SETTINGS_QUERY_KEY = ["app-settings"];
@@ -30,7 +28,6 @@ export type SidebarWorkspaceTrailing = "diff" | "timestamp" | "none";
export type ToolCallDetailLevel = "overview" | "detailed";
const VALID_THEMES = new Set<string>(THEME_OPTIONS.map((option) => option.name));
const ThemePreferenceSchema = z.enum(THEME_OPTIONS.map((option) => option.name));
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]);
const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set<SidebarWorkspaceTrailing>([
@@ -77,45 +74,14 @@ export interface Settings extends AppSettings {
releaseChannel: ReleaseChannel;
}
const SidebarRowItemsSchema = z.strictObject({
host: z.boolean().optional(),
changeRequest: z.boolean().optional(),
services: z.boolean().optional(),
checks: z.boolean().optional(),
scripts: z.boolean().optional(),
});
const StoredAppSettingsSchema = z.strictObject({
theme: ThemePreferenceSchema.optional(),
language: z
.enum(["system", "ar", "en", "es", "fr", "ja", "ko", "pt-BR", "ru", "zh-CN"])
.optional(),
sendBehavior: z.enum(["interrupt", "queue"]).optional(),
serviceUrlBehavior: z.enum(["ask", "in-app", "external"]).optional(),
terminalScrollbackLines: z.union([z.number(), z.string()]).optional(),
useLegacyTerminalRenderer: z.boolean().optional(),
uiFontFamily: z.string().optional(),
monoFontFamily: z.string().optional(),
uiFontSize: z.union([z.number(), z.string()]).optional(),
codeFontSize: z.union([z.number(), z.string()]).optional(),
syntaxTheme: z.string().refine(isSyntaxThemeId).optional(),
workspaceTitleSource: z.enum(["title", "branch"]).optional(),
sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).optional(),
sidebarRowItems: SidebarRowItemsSchema.optional(),
sidebarChecksDisplay: z.enum(["iconAndText", "icon", "none"]).optional(),
autoExpandReasoning: z.boolean().optional(),
toolCallDetailLevel: z.enum(["overview", "detailed"]).optional(),
compactToolCalls: z.boolean().optional(),
chatOutlineEnabled: z.boolean().optional(),
vimKeybindings: z.boolean().optional(),
// COMPAT(rendererDesktopSettings): these fields used to share this renderer-owned key.
manageBuiltInDaemon: z.boolean().optional(),
releaseChannel: z.enum(["stable", "beta"]).optional(),
});
const LegacyRendererSettingsSchema = StoredAppSettingsSchema;
type StoredAppSettings = z.infer<typeof StoredAppSettingsSchema>;
/**
* `sidebarRowItems` is widened back to `unknown` because it is still read for a value the
* current shape no longer has — see `isChecksHiddenByLegacyRowItem`.
*/
type StoredAppSettings = Partial<Omit<AppSettings, "sidebarRowItems">> & {
compactToolCalls?: unknown;
sidebarRowItems?: unknown;
};
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
theme: "auto",
@@ -148,7 +114,6 @@ export const DEFAULT_APP_SETTINGS: Settings = {
export interface KeyValueStorage {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
}
export interface DesktopSettingsBridge {
@@ -181,20 +146,17 @@ export async function saveAppSettings(input: {
export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise<AppSettings> {
try {
const stored = await readValidatedJson(deps.storage, APP_SETTINGS_KEY, StoredAppSettingsSchema);
const stored = await deps.storage.getItem(APP_SETTINGS_KEY);
if (stored) {
return normalizeAppSettings(stored);
return normalizeAppSettings(JSON.parse(stored));
}
const legacyStored = await readValidatedJson(
deps.storage,
LEGACY_SETTINGS_KEY,
LegacyRendererSettingsSchema,
);
const legacyStored = await deps.storage.getItem(LEGACY_SETTINGS_KEY);
if (legacyStored) {
const legacyParsed = JSON.parse(legacyStored) as Record<string, unknown>;
const next = {
...DEFAULT_CLIENT_SETTINGS,
...pickAppSettingsFromLegacy(legacyStored),
...pickAppSettingsFromLegacy(legacyParsed),
} satisfies AppSettings;
await deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
return next;
@@ -235,11 +197,11 @@ export async function loadSettingsFromStorage(deps: SettingsDeps): Promise<Setti
}
export function normalizeAppSettings(value: unknown): AppSettings {
const result = StoredAppSettingsSchema.safeParse(value);
return {
...DEFAULT_CLIENT_SETTINGS,
...pickAppSettings(result.success ? result.data : {}),
};
const stored =
typeof value === "object" && value !== null && !Array.isArray(value)
? (value as StoredAppSettings)
: {};
return { ...DEFAULT_CLIENT_SETTINGS, ...pickAppSettings(stored) };
}
function parseToolCallDetailLevel(stored: StoredAppSettings): ToolCallDetailLevel | null {
@@ -372,9 +334,7 @@ function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
return result;
}
function pickAppSettingsFromLegacy(
legacy: z.infer<typeof LegacyRendererSettingsSchema>,
): Partial<AppSettings> {
function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<AppSettings> {
const result: Partial<AppSettings> = {};
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
result.theme = legacy.theme;
@@ -460,11 +420,15 @@ async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): P
async function loadRendererSettingsPayload(
storage: KeyValueStorage,
): Promise<z.infer<typeof LegacyRendererSettingsSchema> | null> {
const current = await readValidatedJson(storage, APP_SETTINGS_KEY, LegacyRendererSettingsSchema);
): Promise<Record<string, unknown> | null> {
const current = await storage.getItem(APP_SETTINGS_KEY);
if (current) {
return current;
return JSON.parse(current) as Record<string, unknown>;
}
return readValidatedJson(storage, LEGACY_SETTINGS_KEY, LegacyRendererSettingsSchema);
const legacy = await storage.getItem(LEGACY_SETTINGS_KEY);
if (!legacy) {
return null;
}
return JSON.parse(legacy) as Record<string, unknown>;
}
+16 -8
View File
@@ -1,6 +1,5 @@
import { IDENTITY_COLOR_NAMES, type IdentityColorName } from "@/styles/identity-colors";
import type { HostProfile } from "@/types/host-connection";
import { z } from "zod";
export type HostColor = "none" | IdentityColorName;
@@ -20,18 +19,27 @@ export interface HostAppearance {
badgeDisplay: HostBadgeDisplay | null;
}
export const HostAppearanceSchema: z.ZodType<HostAppearance> = z.strictObject({
color: z.enum(["none", ...IDENTITY_COLOR_NAMES]),
badgeDisplay: z.enum(["name", "icon", "hidden"]).nullable(),
});
export function defaultHostAppearance(): HostAppearance {
return { color: "none", badgeDisplay: null };
}
function isHostColor(value: unknown): value is HostColor {
return HOST_COLORS.some((color) => color === value);
}
function isHostBadgeDisplay(value: unknown): value is HostBadgeDisplay {
return HOST_BADGE_DISPLAYS.some((display) => display === value);
}
export function normalizeStoredHostAppearance(value: unknown): HostAppearance {
const result = HostAppearanceSchema.safeParse(value);
return result.success ? result.data : defaultHostAppearance();
if (!value || typeof value !== "object" || Array.isArray(value)) {
return defaultHostAppearance();
}
const record = value as Record<string, unknown>;
return {
color: isHostColor(record.color) ? record.color : "none",
badgeDisplay: isHostBadgeDisplay(record.badgeDisplay) ? record.badgeDisplay : null,
};
}
export function resolveHostBadgeDisplay(input: {
-4
View File
@@ -614,7 +614,6 @@ export const ar: TranslationResources = {
reloadingAgent: "وكيل إعادة التحميل...",
reloadedAgent: "وكيل إعادة تحميل",
failedToReloadAgent: "فشل في إعادة تحميل الوكيل",
failedToCloseAgent: "فشل في إغلاق الوكيل",
},
confirmations: {
unsavedTitle: "تغييرات غير محفوظة",
@@ -2405,9 +2404,6 @@ export const ar: TranslationResources = {
docsTooltip: "راجع المستندات لمزيد من التفاصيل ومتغيرات البيئة المتاحة لهذه الأوامر",
setup: "يثبت",
setupAccessibility: "أوامر إعداد شجرة العمل",
uncommittedTitle: "ثبّت تغييرات paseo.json",
uncommittedDescription:
"تستخدم أشجار العمل الجديدة نص الإعداد البرمجي من الفرع الأساسي الذي تحدده.",
teardown: "هدم",
teardownAccessibility: "أوامر هدم شجرة العمل",
},
-4
View File
@@ -613,7 +613,6 @@ export const en = {
reloadingAgent: "Reloading agent...",
reloadedAgent: "Reloaded agent",
failedToReloadAgent: "Failed to reload agent",
failedToCloseAgent: "Failed to close agent",
},
confirmations: {
close: "Close",
@@ -2419,9 +2418,6 @@ export const en = {
"See docs for more details and the environment variables available to these commands",
setup: "Setup",
setupAccessibility: "Worktree setup commands",
uncommittedTitle: "Commit paseo.json changes",
uncommittedDescription:
"New worktrees use the setup script from the base branch you select.",
teardown: "Teardown",
teardownAccessibility: "Worktree teardown commands",
},
-4
View File
@@ -619,7 +619,6 @@ export const es: TranslationResources = {
reloadingAgent: "Agente de recarga...",
reloadedAgent: "Agente recargado",
failedToReloadAgent: "No se pudo recargar el agente",
failedToCloseAgent: "No se pudo cerrar el agente",
},
confirmations: {
unsavedTitle: "Cambios sin guardar",
@@ -2460,9 +2459,6 @@ export const es: TranslationResources = {
"Consulte los documentos para obtener más detalles y las variables de entorno disponibles para estos comandos.",
setup: "Configuración",
setupAccessibility: "Comandos de configuración del árbol de trabajo",
uncommittedTitle: "Confirma los cambios de paseo.json",
uncommittedDescription:
"Los árboles de trabajo nuevos usan el script de configuración de la rama base que selecciones.",
teardown: "Demoler",
teardownAccessibility: "Comandos de desmontaje del árbol de trabajo",
},
-4
View File
@@ -619,7 +619,6 @@ export const fr: TranslationResources = {
reloadingAgent: "Agent de rechargement...",
reloadedAgent: "Agent rechargé",
failedToReloadAgent: "Échec du rechargement de l'agent",
failedToCloseAgent: "Échec de la fermeture de l'agent",
},
confirmations: {
unsavedTitle: "Modifications non enregistrées",
@@ -2466,9 +2465,6 @@ export const fr: TranslationResources = {
"Voir la documentation pour plus de détails et les variables d'environnement disponibles pour ces commandes",
setup: "Installation",
setupAccessibility: "Commandes de configuration de Worktree",
uncommittedTitle: "Validez les modifications de paseo.json",
uncommittedDescription:
"Les nouveaux worktrees utilisent le script de configuration de la branche de base sélectionnée.",
teardown: "Démolir",
teardownAccessibility: "Commandes de démontage de Worktree",
},
-4
View File
@@ -619,7 +619,6 @@ export const ja: TranslationResources = {
reloadingAgent: "エージェントを再読み込み中...",
reloadedAgent: "エージェントを再読み込みしました",
failedToReloadAgent: "エージェントの再読み込みに失敗しました",
failedToCloseAgent: "エージェントを閉じられませんでした",
},
confirmations: {
unsavedTitle: "未保存の変更",
@@ -2430,9 +2429,6 @@ export const ja: TranslationResources = {
"これらのコマンドで使用可能な詳細と環境変数についてはドキュメントを参照してください",
setup: "セットアップ",
setupAccessibility: "ワークツリーセットアップコマンド",
uncommittedTitle: "paseo.json の変更をコミットしてください",
uncommittedDescription:
"新しいワークツリーでは、選択したベースブランチのセットアップスクリプトが使われます。",
teardown: "削除時",
teardownAccessibility: "ワークツリー削除時のコマンド",
},
-3
View File
@@ -616,7 +616,6 @@ export const ko: TranslationResources = {
reloadingAgent: "에이전트 다시 로드 중...",
reloadedAgent: "에이전트를 다시 로드했습니다",
failedToReloadAgent: "에이전트를 다시 로드하지 못했습니다",
failedToCloseAgent: "에이전트를 닫지 못했습니다",
},
confirmations: {
close: "닫기",
@@ -2422,8 +2421,6 @@ export const ko: TranslationResources = {
docsTooltip: "자세한 내용과 이 명령에 사용할 수 있는 환경 변수는 문서를 참조하세요",
setup: "설정",
setupAccessibility: "워크트리 설정 명령",
uncommittedTitle: "paseo.json 변경 사항을 커밋하세요",
uncommittedDescription: "새 워크트리는 선택한 기본 브랜치의 설정 스크립트를 사용합니다.",
teardown: "정리",
teardownAccessibility: "워크트리 정리 명령",
},
-4
View File
@@ -618,7 +618,6 @@ export const ptBR: TranslationResources = {
reloadingAgent: "Recarregando agente...",
reloadedAgent: "Agente recarregado",
failedToReloadAgent: "Falha ao recarregar agente",
failedToCloseAgent: "Falha ao fechar agente",
},
confirmations: {
unsavedTitle: "Alterações não salvas",
@@ -2445,9 +2444,6 @@ export const ptBR: TranslationResources = {
"Veja a documentação para mais detalhes e as variáveis de ambiente disponíveis para estes comandos",
setup: "Configuração",
setupAccessibility: "Comandos de configuração do worktree",
uncommittedTitle: "Faça commit das alterações no paseo.json",
uncommittedDescription:
"Novos worktrees usam o script de configuração do branch base selecionado.",
teardown: "Desmontagem",
teardownAccessibility: "Comandos de desmontagem do worktree",
},
-4
View File
@@ -618,7 +618,6 @@ export const ru: TranslationResources = {
reloadingAgent: "Перезагрузка агента...",
reloadedAgent: "Перезагруженный агент",
failedToReloadAgent: "Не удалось перезагрузить агент",
failedToCloseAgent: "Не удалось закрыть агент",
},
confirmations: {
unsavedTitle: "Несохранённые изменения",
@@ -2448,9 +2447,6 @@ export const ru: TranslationResources = {
"Дополнительную информацию и переменные среды, доступные для этих команд, см. в документации.",
setup: "Настраивать",
setupAccessibility: "Команды настройки рабочего дерева",
uncommittedTitle: "Закоммитьте изменения paseo.json",
uncommittedDescription:
"Новые рабочие деревья используют сценарий настройки из выбранной базовой ветки.",
teardown: "Срывать",
teardownAccessibility: "Команды разрушения рабочего дерева",
},
-3
View File
@@ -614,7 +614,6 @@ export const zhCN: TranslationResources = {
reloadingAgent: "正在重新加载 Agent...",
reloadedAgent: "已重新加载 Agent",
failedToReloadAgent: "重新加载 Agent 失败",
failedToCloseAgent: "关闭 Agent 失败",
},
confirmations: {
unsavedTitle: "未保存的更改",
@@ -2377,8 +2376,6 @@ export const zhCN: TranslationResources = {
docsTooltip: "查看命令可用的环境变量和更多细节",
setup: "Setup",
setupAccessibility: "Worktree setup 命令",
uncommittedTitle: "提交 paseo.json 更改",
uncommittedDescription: "新工作树使用所选基础分支中的设置脚本。",
teardown: "Teardown",
teardownAccessibility: "Worktree teardown 命令",
},
@@ -404,13 +404,6 @@ describe("keyboard-shortcuts", () => {
},
);
it("leaves Escape to an editable field", () => {
expectNoShortcutResolution({
event: { key: "Escape", code: "Escape" },
context: { focusScope: "editable" },
});
});
const nonMatchingCases: NonMatchingShortcutCase[] = [
{
name: "does not keep old Mod+Alt+N binding",
@@ -76,8 +76,8 @@ interface ShortcutWhen {
terminal?: false;
/** false = disabled when command center is open */
commandCenter?: false;
/** Allowed focus scope or scopes */
focusScope?: KeyboardFocusScope | readonly KeyboardFocusScope[];
/** Exact focus scope match */
focusScope?: KeyboardFocusScope;
}
type ShortcutPayloadDef =
@@ -1042,7 +1042,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
id: "agent-interrupt",
action: "agent.interrupt",
combo: "Escape",
when: { commandCenter: false, focusScope: ["message-input", "other"] },
when: { commandCenter: false, terminal: false },
preventDefault: false,
stopPropagation: false,
help: {
@@ -1215,14 +1215,7 @@ export function matchesKeyboardShortcutContext(
}
if (when.terminal === false && context.focusScope === "terminal") return false;
if (when.commandCenter === false && context.commandCenterOpen) return false;
if (
when.focusScope !== undefined &&
!(typeof when.focusScope === "string"
? context.focusScope === when.focusScope
: when.focusScope.includes(context.focusScope))
) {
return false;
}
if (when.focusScope !== undefined && context.focusScope !== when.focusScope) return false;
return true;
}
@@ -47,14 +47,6 @@ const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = {
],
};
const TEST_PI_DEFINITION: AgentProviderDefinition = {
id: "pi",
label: "Pi",
description: "Pi test provider",
defaultModeId: null,
modes: [],
};
const CODEX_MODELS: AgentModelDefinition[] = [
{
provider: "codex",
@@ -978,6 +970,55 @@ describe("resolveAgentForm", () => {
});
});
describe("SET_PROVIDER_FROM_USER", () => {
it("switches provider, picks preferred model and mode, marks provider modified", () => {
const state = makeState();
const next = resolveAgentForm(state, {
type: "SET_PROVIDER_FROM_USER",
provider: "codex",
providerModels: CODEX_MODELS,
providerDef: TEST_CODEX_DEFINITION,
providerPrefs: { model: "gpt-5.3-codex", mode: "full-access" },
});
expect(next.form.provider).toBe("codex");
expect(next.form.model).toBe("gpt-5.3-codex");
expect(next.form.modeId).toBe("full-access");
expect(next.userModified.provider).toBe(true);
expect(next.userModified.model).toBe(false);
});
it("falls back to provider defaults when no prefs", () => {
const state = makeState();
const next = resolveAgentForm(state, {
type: "SET_PROVIDER_FROM_USER",
provider: "codex",
providerModels: CODEX_MODELS,
providerDef: TEST_CODEX_DEFINITION,
providerPrefs: undefined,
});
expect(next.form.modeId).toBe("auto");
expect(next.form.model).toBe("gpt-5.3-codex");
});
it("canonicalizes an aliased preferred model and restores its thinking option", () => {
const next = resolveAgentForm(makeState(), {
type: "SET_PROVIDER_FROM_USER",
provider: "codex",
providerModels: ALIASED_CODEX_MODELS,
providerDef: TEST_CODEX_DEFINITION,
providerPrefs: {
model: "gpt-5.3-codex-legacy",
thinkingByModel: { "gpt-5.3-codex-legacy": "low" },
},
});
expect(next.form.model).toBe("gpt-5.3-codex");
expect(next.form.thinkingOptionId).toBe("low");
});
});
describe("SET_PROVIDER_AND_MODEL_FROM_USER", () => {
it("sets provider, model, and default mode; marks both modified", () => {
const state = makeState();
@@ -1049,42 +1090,6 @@ describe("resolveAgentForm", () => {
});
});
describe("APPLY_PROFILE_FROM_USER", () => {
it("drops a stale saved mode for a modeless profile provider", () => {
const next = resolveAgentForm(makeState({ provider: "codex", modeId: "full-access" }), {
type: "APPLY_PROFILE_FROM_USER",
provider: "pi",
modelId: "anthropic/sonnet",
modeId: "",
thinkingOptionId: "",
providerDef: TEST_PI_DEFINITION,
providerModels: [{ provider: "pi", id: "anthropic/sonnet", label: "Sonnet" }],
providerPrefs: { mode: "full-access" },
});
expect(next.form).toMatchObject({
provider: "pi",
model: "anthropic/sonnet",
modeId: "",
});
});
it("restores thinking for the selected model when the profile omits it", () => {
const next = resolveAgentForm(makeState(), {
type: "APPLY_PROFILE_FROM_USER",
provider: "codex",
modelId: "gpt-5.3-codex",
modeId: "full-access",
thinkingOptionId: "",
providerDef: TEST_CODEX_DEFINITION,
providerModels: CODEX_MODELS,
providerPrefs: { thinkingByModel: { "gpt-5.3-codex": "low" } },
});
expect(next.form.thinkingOptionId).toBe("low");
});
});
describe("SET_MODEL_FROM_USER", () => {
it("updates model and resets thinking to model default when thinking is not user-modified", () => {
const state = makeState({ provider: "codex", model: "", thinkingOptionId: "" });
@@ -79,6 +79,13 @@ export type AgentFormAction =
}
| { type: "SET_SERVER_ID"; value: string | null }
| { type: "SET_SERVER_ID_FROM_USER"; value: string | null }
| {
type: "SET_PROVIDER_FROM_USER";
provider: AgentProvider;
providerModels: AgentModelDefinition[] | null;
providerDef: AgentProviderDefinition | undefined;
providerPrefs: ProviderPrefs | undefined;
}
| {
type: "SET_PROVIDER_AND_MODEL_FROM_USER";
provider: AgentProvider;
@@ -87,16 +94,6 @@ export type AgentFormAction =
providerModels: AgentModelDefinition[] | null;
providerPrefs?: ProviderPrefs | undefined;
}
| {
type: "APPLY_PROFILE_FROM_USER";
provider: AgentProvider;
modelId: string;
modeId: string;
thinkingOptionId: string;
providerDef: AgentProviderDefinition | undefined;
providerModels: AgentModelDefinition[] | null;
providerPrefs?: ProviderPrefs | undefined;
}
| { type: "SET_MODE_FROM_USER"; modeId: string }
| {
type: "SET_MODEL_FROM_USER";
@@ -112,7 +109,6 @@ export type AgentFormAction =
| { type: "RESET" };
type CompleteResolutionAction = Extract<AgentFormAction, { type: "COMPLETE_RESOLUTION" }>;
type ApplyProfileAction = Extract<AgentFormAction, { type: "APPLY_PROFILE_FROM_USER" }>;
export function normalizeSelectedModelId(modelId: string | null | undefined): string {
return typeof modelId === "string" ? modelId.trim() : "";
@@ -488,6 +484,20 @@ export function resolveFormStateFromProviderModels(
);
}
function pickNextModelForProvider(input: {
providerModels: AgentModelDefinition[] | null;
providerPrefs: ProviderPrefs | undefined;
}): string {
const { providerModels, providerPrefs } = input;
const preferredModel = normalizeSelectedModelId(providerPrefs?.model);
const defaultModelId = resolveDefaultModelId(providerModels);
if (preferredModel) {
if (!providerModels) return preferredModel;
return resolveCanonicalModelId(providerModels, preferredModel) || defaultModelId;
}
return defaultModelId;
}
function pickNextModeForProvider(input: {
providerDef: AgentProviderDefinition | undefined;
providerPrefs: ProviderPrefs | undefined;
@@ -576,45 +586,6 @@ function completeResolution(
return { ...nextState, form: resolved };
}
function applyProfile(state: AgentFormReducerState, action: ApplyProfileAction) {
const preferredModelId = action.modelId || action.providerPrefs?.model || "";
const normalizedModelId = resolveCanonicalModelId(action.providerModels, preferredModelId);
const nextModelId = normalizedModelId || resolveDefaultModelId(action.providerModels);
const availableModeIds = new Set(action.providerDef?.modes.map((mode) => mode.id) ?? []);
const preferredModeId = action.modeId || action.providerPrefs?.mode || "";
const defaultModeId = action.providerDef?.defaultModeId ?? "";
let nextModeId = "";
if (availableModeIds.has(preferredModeId)) {
nextModeId = preferredModeId;
} else if (availableModeIds.has(defaultModeId)) {
nextModeId = defaultModeId;
}
const nextThinkingOptionId =
action.thinkingOptionId ||
pickNextThinkingOptionForProvider({
providerModels: action.providerModels,
providerPrefs: action.providerPrefs,
modelId: nextModelId,
});
return {
...state,
form: {
...state.form,
provider: action.provider,
model: nextModelId,
modeId: nextModeId,
thinkingOptionId: nextThinkingOptionId,
},
userModified: {
...state.userModified,
provider: true,
model: true,
modeId: true,
thinkingOptionId: true,
},
};
}
export function resolveAgentForm(
state: AgentFormReducerState,
action: AgentFormAction,
@@ -640,6 +611,33 @@ export function resolveAgentForm(
userModified: { ...state.userModified, serverId: true },
};
case "SET_PROVIDER_FROM_USER": {
const nextModelId = pickNextModelForProvider({
providerModels: action.providerModels,
providerPrefs: action.providerPrefs,
});
const nextModeId = pickNextModeForProvider({
providerDef: action.providerDef,
providerPrefs: action.providerPrefs,
});
const nextThinkingOptionId = pickNextThinkingOptionForProvider({
providerModels: action.providerModels,
providerPrefs: action.providerPrefs,
modelId: nextModelId,
});
return {
...state,
form: {
...state.form,
provider: action.provider,
modeId: nextModeId,
model: nextModelId,
thinkingOptionId: nextThinkingOptionId,
},
userModified: { ...state.userModified, provider: true },
};
}
case "SET_PROVIDER_AND_MODEL_FROM_USER": {
const normalizedModelId = resolveCanonicalModelId(action.providerModels, action.modelId);
const nextModelId = normalizedModelId || resolveDefaultModelId(action.providerModels);
@@ -671,10 +669,6 @@ export function resolveAgentForm(
};
}
case "APPLY_PROFILE_FROM_USER": {
return applyProfile(state, action);
}
case "SET_MODE_FROM_USER":
return {
...state,

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