Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 4d08ddba94 fix(app): prevent concurrent draft agent creation
React submit state is render-lagged, so callbacks entering in the same render can all start create requests. Claim the draft synchronously in the shared create-flow store before the first request begins.
2026-08-12 11:45:51 +00:00
Mohamed Boudra 2f6b585a3a fix(desktop): shut down cleanly with workspace terminals (#3268)
Workspace terminal shutdown only signaled an npm wrapper, leaving Electron alive with broken output pipes. Make the signal-handling runner the terminal-owned process and route external Electron termination through the existing quit lifecycle.
2026-08-12 13:00:55 +02:00
2c5c629db8 fix(usage): read Cursor token from modern state.vscdb key via node:sqlite (#2704)
* fix(usage): read Cursor token from modern state.vscdb key via node:sqlite

Settings → Usage showed Cursor as unavailable for a normal Cursor desktop
login. The quota fetcher read the access token from the legacy `cursorAuthStatus`
key, but current Cursor builds store a plain JWT under `cursorAuth/accessToken`
with no such legacy row, so the lookup returned null.

Read the token with built-in node:sqlite instead of shelling out to a `sqlite3`
CLI (absent by default on Windows) — a second, independent cause of the same
"token not found → unavailable" failure. Prefer the modern key, fall back to the
legacy `cursorAuthStatus` blob for older builds, and log read failures at debug
so an unavailable card stays diagnosable.

Fixes #2586

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(usage): cover Cursor sqlite text and blob auth

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-12 12:25:04 +02:00
Mohamed Boudra 6ed364ffe2 Document per-execution Hub worktree branch names (#3267)
* docs(hub): document per-execution worktree branch names

Hub now materializes `${{ paseo.execution.id }}` inside a daemon
environment's `worktree.newBranch` before it persists or dispatches the
launch, so a reusable branch-off environment can get a stable branch per
execution instead of every trigger contending for one literal name.

The reference states the constraint precisely because it is the part the
YAML cannot show: `newBranch` accepts that one execution-scoped path and
nothing else, and any other expression fails bundle activation with the
authored file and field in the error. Literal branch names stay valid.

* docs(hub): reshape the per-execution worktree branch docs

Split the two pages by the job each reader is doing. The daemon page is a
task guide, so it now carries the worktree YAML and only the two facts
that reader needs to choose a branch name, then links out. The reference
owns the exhaustive contract at the `newBranch` field: a table contrasting
a literal with the template, then the accepted expression scope and what
activation rejects.

Dropped the render-before-dispatch ordering. Readers cannot act on where
Hub materializes the value; the reachable behavior is that a retried or
recovered execution keeps its branch, which is what the table says.

* docs(hub): make the per-execution worktree branch the only guidance

A branch-off Hub environment that aims every execution at one branch name
is not a configuration worth teaching, so the docs no longer present it as
an alternative. Both pages now show `newBranch: trigger-${{ paseo.execution.id }}`
and say what it does, with no literal-branch example and no comparison.

The reference keeps the field's type as a bare syntax fact, along with the
accepted expression, what activation rejects, and the authored field the
failure names.
2026-08-12 18:20:08 +08:00
Mohamed Boudra 74f2158c45 Preserve daemon sessions when workers stall (#3263)
Stop treating heartbeat silence as proof that a live daemon worker should be destroyed. Crash recovery, explicit lifecycle requests, and supervisor-loss cleanup remain unchanged.
2026-08-12 10:58:58 +02:00
Mohamed Boudra d012b6f071 Help agents diagnose Paseo provider problems (#3243)
* feat(skills): add Paseo help and provider diagnostics

Give support agents a topology-aware troubleshooting entry point backed by the deployed documentation index, and expose the daemon provider diagnostic through the CLI so agents can inspect the affected host directly. Agent-launching skills now use configured profiles instead of the removed orchestration preferences.

* test(cli): keep provider diagnostics on real boundaries

Remove the internal client mock and preserve its normalization and host-targeting assertions through the existing isolated-daemon coverage.
2026-08-12 00:57:33 +02:00
Mohamed Boudra 5f5c585e68 fix(app): avoid unsupported Android array sorting (#3241)
Hermes in the production Android app does not provide Array.prototype.toSorted. Multi-host project selection and provider cache eviction could therefore crash when they reached deterministic sorting paths.
2026-08-12 00:23:22 +02:00
Mohamed Boudra 23d7a8eaf9 Improve Markdown file preview readability (#3240)
* feat(app): improve markdown file previews

Keep rendered documents on the chat reading rail, surface YAML front matter as readable metadata, and make shared Markdown links retain the chat accent and hover treatment.

* fix(app): preserve unsafe frontmatter source

* test(app): cover markdown link behavior
2026-08-12 00:18:30 +02:00
Mohamed Boudra 7b167784d7 fix(app): bound Android provider snapshot storage (#3234)
Android AsyncStorage's 6 MiB SQLite ceiling was exhausted by per-workspace provider snapshots with no eviction. Raise the native cap to 10 MiB and bound snapshots to 4 MiB with indexed eviction, clearing legacy unindexed data without scanning payloads on every save.
2026-08-11 22:00:21 +02:00
Mohamed Boudra 40bdb9a043 fix(server): keep daemon workers alive across host sleep (#3235)
The worker watchdog treated wall-clock time spent in host sleep as evidence that the worker was unresponsive. Count consecutive observed heartbeat misses instead so sleep does not destroy live terminal sessions while genuinely stalled workers still restart after the same active observation window.
2026-08-11 21:48:06 +02:00
Mohamed Boudra 2f54c0e309 Use saved Project Settings for new worktree setup (#3233)
* fix(worktrees): honor saved setup configuration

Project Settings writes paseo.json in the source checkout, but existing committed copies in new worktrees previously won silently. Copy the saved project config before lifecycle setup so the command shown to users is the command that runs.

* fix(worktrees): replace config symlinks safely

A selected ref can place a symlink at paseo.json, so copying over that path may overwrite a file outside the worktree. Materialize the saved source config with atomic rename semantics to replace the path entry without following it.
2026-08-11 21:20:30 +02:00
Mohamed Boudra b9d7d4b18e Reduce storage writes during agent streaming (#3232)
* perf(app): skip transient replica cache writes

The replica cache subscribed to the entire session store, so live stream-head updates forced repeated JSON serialization and browser storage writes even though the head is not persisted. Select the cache's persisted inputs once and schedule writes only when that projection changes.

* test(app): move perf cleanup into fixture

Keep the replica-cache performance scenario as a direct setup, stream, measure, and assert flow while the spec-local Playwright fixture owns deterministic mock-agent cleanup.
2026-08-11 19:42:51 +02:00
Mohamed Boudra 1c905a114e Remove unused session message processing (#3231)
* perf(app): remove unused session message state

The legacy message accumulator had no readers but copied its full array for each activity and updated Zustand for every assistant chunk. Keep only the live voice-abort notification and voice transcription behavior.

* test(app): use live session action in task subscription test
2026-08-11 19:28:35 +02:00
163e7d1cc4 fix(omp): negotiate RPC protocol v2 so large model catalogs don't overflow (#3184)
* fix(omp): negotiate RPC protocol v2 so large responses don't overflow

OMP caps protocol-v1 single-line RPC frames at 1 MiB. get_available_models
returns the full effective model catalog (hundreds of models), which exceeds
that cap, so OMP replies with an overflow error ("RPC response exceeded the
transport limit") and the OMP provider fails to start sessions.

Negotiate RPC protocol v2 after OMP's startup `ready` frame when it is
supported: v2 lifts the single-frame ceiling to 64 MiB by chunking oversized
frames. Add rpc_chunk reassembly to the shared JSONL transport so chunked
responses are reconstructed before routing, and gate the negotiation on the
ready frame so it is in place before the first request.

Protocol v2 is negotiated only when the peer advertises support in its ready
frame; peers that stay on v1 (e.g. Pi's --mode rpc) are unaffected.

* fix(omp): make protocol startup atomic

---------

Co-authored-by: pi3123 <pi3123@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-11 19:19:24 +02:00
Hanjun(Tony)andThong Van cdb1163145 Notify callers when watched children close and cap responses (#3192)
Wake caller agents when watched children close and cap embedded child responses at 4,000 characters.

Co-authored-by: Hanjun(Tony) <54878785+wilgon456@users.noreply.github.com>
Co-authored-by: Thong Van <minhthong@gmail.com>
2026-08-11 19:08:41 +02:00
Edi HasajandMohamed Boudra f24664b882 fix: terminalize Codex compaction on turn end (#3211)
* fix: terminalize Codex compaction on turn end

* fix: ignore stale Codex compaction completions

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-11 19:02:17 +02:00
Martin HanzíkandClaude Opus 5 a39959219f feat: Implement workspace rename cli (#3209)
* feat(cli): add workspace rename command

`paseo workspace rename <workspace-id> <title>` sets a workspace's
user-visible title through the existing workspace.title.set RPC, and
`--reset` clears the override so the name reverts to the branch or
directory. The command reads the descriptor back so the output shows
what a reset reverted to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cli): harden workspace rename against read-back and arg failures

Review findings on the rename command:

- A failed workspace list sweep after a successful title change reported
  the whole command as failed. The read-back is display-only, so it now
  runs outside the mutation's error path and degrades to a null name.
- Only --reset needs the descriptor read back; a set title is already the
  resolved display name, so the common path no longer pages the list.
- Dropped the `"code" in error` rethrow. Nothing in that block throws a
  CommandError, but DaemonRpcError always carries a `code` property even
  when it is undefined, so the guard let those through to render as
  UNKNOWN_ERROR with a stack instead of WORKSPACE_RENAME_FAILED.
- Commander 12 allows excess positionals, so an unquoted multi-word title
  renamed to its first word and dropped the rest. The command now rejects
  excess arguments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(cli): report the rename RPC response instead of reading back

Reading the descriptor back to show what --reset reverted to was the only
reason rename needed a paged workspace sweep, and that sweep dragged in
everything around it: a failure path that could report an applied rename
as failed, a nullable name, and a bespoke warning sink to tell the two
null cases apart.

Sibling commands don't do this. `workspace archive`, `agent mode`, and
`schedule update` all report what the RPC returned and stop; the one
command that reads back is `agent update`, which has the same "applied
but reported as failed" bug. So rename now mirrors `workspace archive`.

`--reset` reports `title: null` rather than naming what it reverted to;
`paseo workspace ls` shows the resolved name. This also reverts the
`collectWorkspaces` extraction, whose only second caller was rename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:36:58 +00:00
Mohamed Boudra db6520bf36 Show live task progress while agents work (#3227)
* feat(agents): surface live task progress

Keep current task state above the composer and turn task snapshots into semantic timeline activity. Normalize provider task protocols while preserving genuine plan flows.

* fix(agents): admit tasks through stream sequencing

* test(agents): expect normalized task status
2026-08-11 18:23:30 +02:00
Mohamed Boudra b2ff359b60 Always create a fresh worktree (#3224)
* fix(server): always create requested worktrees

A slug is a preferred name, not workspace identity. Let the worktree creator suffix occupied paths and branches so duplicate and detached-slug requests still create fresh worktrees.

* fix(server): suffix occupied checkout branches

Fresh creation also applies when checkout targets are already in use. Create a suffixed branch from the requested branch instead of rejecting the request.

* test(server): align occupied branch coverage

* test(server): remove stale worktree reuse expectations
2026-08-11 17:54:59 +02:00
Mohamed Boudra 308e9af845 Add reusable agent profiles (#3208)
* Search models across all providers from the picker root

* Store agent profiles in daemon config and apply agent config in one RPC

An agent profile is a named bundle of provider, model, mode, thinking
option, feature values and notes, stored host-wide next to terminal
profiles. It carries no system prompt: AgentSessionConfig.systemPrompt is
creation-only, so a profile holding one would apply when starting an agent
and silently do nothing when applied to a running one.

Applying a profile through the four single-field RPCs can fail halfway and
leave an agent matching no profile, so agent.config.apply takes the whole
bundle. Model runs first because a thinking option only means anything
against the model that offers it.

Old daemons parse their persisted config strictly and would drop an
unknown agentProfiles key, making a save appear to succeed and vanish, so
both capabilities are advertised on server_info for the client to gate on.

list_profiles exposes the profiles to orchestrating agents, which read the
notes to pick one and copy its values into create_agent.

* Add the agent profiles settings section

Profiles live behind one module boundary so removing the feature is a
directory plus one mount, which is what favourites never was. The picker
and composer will ask for rows and an apply function rather than learning
what a profile contains.

The edit form follows the form-model pattern instead of the terminal
profile modal's per-field state, because provider selection cascades into
which models, modes, thinking levels and features exist below it.

The section is gated on server_info.features.agentProfiles: an older daemon
drops the key on write, and a save that appears to work and then vanishes
is worse than an unavailable state.

* Document agent profiles and name the four senses of profile

The glossary reserved Profile for HostProfile, terminal profiles owned the
user-facing word, and custom-providers.md used it for provider aliases.
Agent profile is now the term, with all four cross-referenced.

data-model.md never documented terminalProfiles either, so both profile
lists are backfilled together along with the whole-list patch semantics and
why absent and empty differ.

* Pin agent profiles to the model picker and delete favourites

Applying a profile writes its values into the agent controls and is then
forgotten, so rows carry no checkmark and no selected state: after applying,
nothing is selected. Chevron still means navigate deeper, so the absence of
one is what marks a row as an action.

A running agent is its provider's process and agent.config.apply carries no
provider, so the live picker only offers profiles matching the agent's
provider rather than showing rows that cannot do what they say. The draft
path has no such limit and switches provider freely.

Draft feature values go through preferences rather than the feature
callback alone, because the draft feature hook clears local values whenever
the provider changes and a provider-switching profile would lose them.

Favourites are deleted rather than migrated: they were device-local
AsyncStorage keyed by provider and model, while profiles are host-wide
daemon config. ProviderSelectionModelRow.favoriteKey stays as row identity
and moves to its real owner; renaming it is its own commit.

* Cover agent profiles and cross-provider search with E2E specs

Model rows expressed selection only as a checkmark glyph, which assistive
tech cannot see and a spec cannot assert. Rows that draw the indicator now
carry aria-selected; profile rows deliberately carry none, because a
profile is an action rather than a selection. That is what makes "nothing
is marked selected after applying a profile" a real assertion instead of a
tautology, with the materialized model one level down as the control.

The search fixture extends claude rather than mock: provider-config.ts
only allows the six shipped providers as extends targets, so a derived
provider cannot inherit the dev-only mock provider.

* State what applying a config bundle does on partial failure

applyBundle is not a transaction and the earlier commit message implied it
was. A step that rejects aborts the rest and the steps that already applied
stay applied, which a profile omitting modelId can reach: provider setters
validate against the agent's live model, so mode can land while a feature
that model does not support is rejected.

Nothing is hidden by that. Every setter emits agent state before returning,
so what applied is streamed to the client next to the rejected response.
Rolling back would re-invoke the setters that just failed, usually against
a session that is gone, and pre-flight validation would restate each
provider's model-dependent checks where they would drift from the setters
that own them.

What sending the bundle in one request removes is the multi-round-trip
failure class: interruption between hops, and interleaving with another
client. The test already pinned this behaviour; its name now says so.

* feat(agent-profiles): refine profile discovery and appearance

Make profiles discoverable from empty and provider-scoped model pickers, keep global search stable and virtualized, and move profile settings under Agents. Replace emoji identity with a shared Lucide icon and host-color picker, while requiring concrete provider selections so profiles materialize predictably.

* fix(agent-profiles): defer profile swatch styles

Resolving Unistyles proxies at module load can freeze the temporary startup color scheme before the persisted theme is restored. Keep only style-key names at module scope and resolve the selected swatch during render.
2026-08-11 17:42:35 +02:00
Christoph LeiterandClaude Opus 5 07044136c8 feat(app): switch sidebar Group by from the command center (#3063)
Switching the sidebar between Project and Status grouping takes three
clicks today: open the display preferences menu, open Grouping, pick the
mode. It is the only sidebar preference with no fast path.

Typing "group" in the command center now offers a single entry that
switches to the mode you are not in, so it reads "Group by status" while
grouped by project and the reverse once you are in status mode. With
two modes a "cycle grouping" label would not say where you land, and an
entry that reads the same in both states looks like it does nothing.

The entry is query-only, so the default Cmd-K list is unchanged. It does
not clear the focus restore element, because it navigates nowhere and
focus has to return to whatever you were typing in.

The contribution is built by a pure function in root-contributions.ts so
its branches can be unit tested, mirroring workspace-contributions.ts.
The registration subscribes to groupMode with a narrow selector to avoid
re-registering every root action when host filters are reconciled.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 14:35:10 +00:00
Christoph LeiterandClaude Sonnet 5 b7ca1f7502 docs: fix typos across contributing, product, and skill docs (#3153)
Fix spelling and grammar errors found in a full sweep of the
repo's English markdown files.

- CONTRIBUTING.md: "contributing to to it", "accpeted", "wil"
- .github/PULL_REQUEST_TEMPLATE.md: "value provider to" instead
  of "value provided to"
- docs/product.md: "vendor-lock in" instead of "vendor lock-in"
- packages/expo-two-way-audio/CONTRIBUTING.md: "prescreptive"
- packages/expo-two-way-audio/README.md: "can bee added"
- skills/paseo-committee/SKILL.md: "considered and reject"

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 13:53:38 +00:00
Mohamed Boudra 917e39495b Show the source branch on development builds (#3216)
Development web and Electron builds now show their source branch in the expanded sidebar, including a branch icon and safe truncation. Dev launchers inject the label at build time; production builds remain unchanged.
2026-08-11 14:18:06 +02:00
Mohamed Boudra 8c003351d0 fix(app): keep theme selection consistent (#3214)
Theme registration, the appearance picker, persistence, and shortcut cycling drifted as new choices were added. Make one ordered catalog own those paths so Auto and Pure Black participate consistently, while lifting the Pure Black selected-workspace surface above its sidebar.
2026-08-11 14:09:44 +02:00
Mohamed Boudra 36a13601dd Let users choose the metadata generation model (#3215)
Expose Automatic and Manual metadata model selection in Host Settings, preserve existing configured fallbacks, and keep structured generation resilient through discovered candidates. Includes Playwright coverage, translations, public documentation, and settings-row alignment polish.
2026-08-11 14:01:00 +02:00
Mohamed Boudra b599d38a77 Make large diffs open quickly and scroll as one surface (#3212)
* perf(app): virtualize large diff rendering

Render bounded diff row groups through one vertical list and one horizontal surface. Bound retained list ranges when expansion grows the data set so large diffs paint promptly without sacrificing scrolling behavior.

* refactor(app): normalize working tree diff options

* fix(app): keep wide diff text reachable
2026-08-11 12:18:48 +02:00
7253228dc5 feat(app): render mermaid diagrams in agent chat with pan/zoom (#2306)
* feat(app): render mermaid diagrams in agent chat with pan/zoom

Fenced mermaid blocks in assistant messages now render as diagrams
instead of code blocks.

Web: mermaid renders inline via dynamic import (code-split, loads on
first diagram). Drag to pan, ctrl/cmd+wheel or trackpad pinch to zoom
about the cursor, double-click to reset; plain wheel still scrolls the
chat. Rendered SVGs are cached across virtualization remounts.

iOS/Android: diagrams render in a WebView running a self-contained
generated bundle (build:mermaid-webview, mirrors the native terminal's
xterm pattern — no CDN, mermaid ships in the app). The inline preview
is touch-shielded and height-driven by a bridge message; tapping opens
a fullscreen viewer where the platform WebView owns pinch-zoom.

Streaming source that doesn't parse yet falls back to the highlighted
code block and keeps the last good render; renders are serialized
through one queue with stale-task skipping so token-by-token updates
cost at most one in-flight layout.

Security: mermaid can fetch external resources during layout (image
shapes, CSS url()) before output sanitization runs — see
mermaid-js/mermaid#7645 — so resource-bearing source is rejected
up front (containsUnsafeMermaidSource) on every platform, htmlLabels
stay off, securityLevel is strict, and the mermaid secure list is
extended so directives can't override app theming or themeCSS.

The lockfile diff includes npm 11.17 normalization churn beyond the
mermaid closure; a no-op npm install produces the same rewrite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(app): serialize native mermaid renders and pin viewer to last good source

Serialize render messages inside the mermaid webview entry so a newer
streamed update can't re-initialize global mermaid state (e.g. flip the
theme) while a prior layout is still running; superseded queued renders
are skipped. Track the last successfully rendered source on the RN side
and open the fullscreen viewer with that, so tapping mid-stream while
the latest chunk is still invalid can't show a blank viewer.

Addresses PR review feedback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(app): pair native mermaid renders with their source via request id

A "rendered" bridge event was credited to the newest sent source, but an
older render can complete while a newer (possibly invalid) chunk's send
is in flight, mis-crediting the older render's success to the newer
source. Render messages now carry a request id that the entry echoes on
rendered/renderError; the RN side resolves the source by id, so the
fullscreen viewer always opens the exact source that produced the
visible render.

Addresses PR review feedback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(app): harden mermaid guard and native preview per adversarial review

- Guard bypass (blocker): mermaid accepts quoted and unicode-escaped
  object keys, so quoted "img": evaded the resource denylist while
  still parsing into a real img property. The guard now decodes u/x
  escapes and strips quoting characters before matching, and tests
  both raw and normalized source.
- structuredClone polyfill banner in the webview bundle: the app's iOS
  floor is 15.1 but structuredClone exists only from 15.4; esbuild
  targets don't polyfill runtime APIs.
- Preview height sizes the inner content view so fence padding/borders
  no longer clip the reported SVG height, and measurement happens at
  the final content width (measuring overlay keeps the fence insets).
- The invisible measuring overlay is hit-test transparent, so an
  invalid diagram's fallback code block keeps its copy button and
  gestures.
- renderError now prunes all superseded source-map entries, matching
  the rendered path, so a long broken stream can't accumulate sources.
- The entry's render chain can no longer be poisoned: the whole task
  body is inside try/catch and the chain appends a trailing catch.
- "Mermaid diagram" accessibility label is translated in all locales
  (message.mermaidDiagram) on web and native.
- esbuild pinned in packages/app devDependencies so the generated
  webview artifact doesn't depend on hoisting layout; </script is
  escaped in the generated JS.

Addresses adversarial review round 2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(app): mermaid source toggle and visible zoom controls

Web: a hover control cluster on the diagram (zoom in/out anchored to
the viewport center, reset view, view source) mirroring the code
block's hover-copy idiom; always visible on compact form factors where
hover doesn't exist. View source swaps to the highlighted code block
(with its own copy button) and a view-diagram button swaps back.

Native: the fullscreen viewer gains a source toggle beside the close
button, swapping the webview for the highlighted source in a
scrollable view.

All control labels are translated in every locale under
message.mermaid.*; the flat mermaidDiagram key from the previous
commit is folded into the same group.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(app): align mermaid control icons with the app's icon vocabulary

Network is the settings Connections icon — reusing it for "view
diagram" gave one glyph two meanings; use Workflow instead. RotateCw is
the app's established reset/reload glyph (RotateCcw had no precedent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(app): use Scan for mermaid reset-view control

RotateCw is the app's reload glyph (19 uses) — a reset-zoom button
wearing it invites a reload misread. Scan (corner-bracket fit-to-frame)
is unused and carries the intended fit/reset semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(app): remove raw NUL byte from mermaid cache key separator

A literal 0x00 in the template string made git classify the file as
binary, rendering the PR diff for it unreviewable. Use the escaped
sequence instead — identical runtime key, plain-text source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(app): align mermaid source-view toggle with the copy button

The source view wrapped the code block while the fence margins stayed
inside it, so the floating view-diagram toggle hung in the margin
whitespace above the visible box — misaligned with the code block's
copy button and outside its hover region. Hoist the margins onto the
wrapper (both buttons now share the box edge) and render the toggle in
the copy button's plain style so the pair reads as one control row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(app): close mermaid review findings

* chore(app): finish Mermaid PR rebase cleanup

* refactor(app): isolate diagram rendering and stabilize streaming

* fix(app): keep Mermaid previews stable while streaming

* fix(nix): update npm dependency hash

* fix(app): preserve streamed rows through completion

---------

Co-authored-by: Damon Meledones <dmeledon@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-11 14:46:05 +08:00
Mohamed Boudra 334bf62370 Keep delegated-agent notifications alive through permission prompts (#3177)
* fix(agents): preserve permission notification callbacks

Permission requests consumed the one-shot completion watcher, so callers never heard about later approvals or the final result. Keep the watcher alive across permission checkpoints and include the actionable request payload in each notification.

* fix(agents): notify concurrent permission requests
2026-08-11 00:14:27 +02:00
Mohamed Boudra 6aa9f92156 Stop notifications after removing a host (#3176)
* fix(notifications): stop pushes after host removal

Push tokens were persisted indefinitely with no relationship to app pairing state. Give registrations a 48-hour renewable lease so offline unpairing converges, and revoke immediately when the host is reachable.

* fix(notifications): make token revocation bounded and durable
2026-08-10 23:33:31 +02:00
Mohamed Boudra 29ae25d7aa Show tooltips for all composer controls (#3173)
* fix(app): restore composer control tooltips

Composite composer triggers dropped tooltip interaction props or the anchor ref, so hover either never opened the tooltip or left it off-screen. Preserve pressable props and compose menu refs at the trigger boundaries.

* fix(app): align mode tooltip typography
2026-08-10 21:57:50 +02:00
Mohamed Boudra df9e8e8f06 Use daemon hostnames for initial Hub slugs (#3172)
* fix(hub): send hostname during daemon enrollment

Pass the local hostname as daemon enrollment metadata so Hub can assign useful default slugs without coupling daemon authority to the human CLI login. Document the rollout dependency on Hub accepting the optional field.

* test(hub): verify enrollment hostname payload
2026-08-10 21:55:55 +02:00
Mohamed Boudra 94bda1f925 Remove chat rooms and agent loops before storage migration (#3053)
* refactor(storage): remove chat rooms and agent loops

This is the prerequisite cleanup for the storage backend migration. Legacy wire schemas remain temporarily so mixed-version peers can still parse old messages, while daemon callers now use query-shaped AgentStorage methods that can map directly to SQL later.

* test(cli): remove retired chat and loop coverage
2026-08-10 21:06:47 +02:00
Mohamed Boudra c5d5441c9c Preserve dictation recordings across WebSocket interruptions (#3159)
* fix(app): preserve dictation audio across transport failures

Keep captured PCM in the existing local segment buffer when a WebSocket send fails. Finish only after server acknowledgements and fail an idle drain so the retry path can resend the complete recording.

* fix(app): dispose dictation stream sender cleanly
2026-08-11 02:03:28 +08:00
nikuscsandMohamed Boudra ca2b633286 feat: add file manager context actions (#3027)
* feat(app): add file manager context actions

* fix: reshape file context actions

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-08-10 18:18:23 +02:00
364 changed files with 23194 additions and 10150 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ Closes #
### Reasoning
<!-- A short description of the reasoning of this change in your own words. What was wrong, and how do you hope this PR fixes it. If you can't explain this briefly, the PR is probably too big. This description must be grounded in real user flows and framed as value provider to Paseo users. -->
<!-- A short description of the reasoning of this change in your own words. What was wrong, and how do you hope this PR fixes it. If you can't explain this briefly, the PR is probably too big. This description must be grounded in real user flows and framed as value provided to Paseo users. -->
### Goals
+3 -3
View File
@@ -6,7 +6,7 @@ Thank you for taking the time to contribute to Paseo.
Paseo is an opinionated product, built on freedom and flexibility: any agent provider, any device, running on your own machine. It is meant to be composable, so you can build the workflow you want. Read more about the product vision [here](docs/product.md).
Given Paseo's scope, contributing to to it takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
Given Paseo's scope, contributing to it takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
I pick what to build based on whether it fits the product, how many workflows it improves, whether it keeps things composable, whether we can hold the quality bar, and whether I want to build it.
@@ -72,8 +72,8 @@ Here is the criteria I use to decide:
- PRs that were explicitly approved in a discussion are preferred.
- Unsolicited PR can be closed without a detailed review.
- Your PR can be narrowed, refactored, or redesigned.
- Your PR might be accpeted but not merged immediately.
- You wil be attributed for your work in the changelog, even if I redesign your PR
- Your PR might be accepted but not merged immediately.
- You will be attributed for your work in the changelog, even if I redesign your PR
## QA evidence
-1
View File
@@ -118,7 +118,6 @@ npx skills add getpaseo/paseo
どのエージェントとの会話でも使用できます。
- `/paseo-handoff` — エージェント間で作業を引き継ぎます。私はこれを使って Claude で計画し、Codex に実装を引き継いでいます。
- `/paseo-loop` — 明確な受け入れ基準に沿ってエージェントをループさせます(Ralph loops とも呼ばれます)。検証役を追加することもできます。
- `/paseo-advisor` — 単一のエージェントをアドバイザーとして起動し、作業を委任せずにセカンドオピニオンを得ます。
- `/paseo-committee` — 対照的な2つのエージェントで委員会を構成し、一歩引いた視点で根本原因を分析して計画を作成します。
-1
View File
@@ -128,7 +128,6 @@ npx skills add getpaseo/paseo
그런 다음 어떤 에이전트 대화에서든 아래 명령을 사용할 수 있습니다.
- `/paseo-handoff` — 에이전트 간에 작업을 넘깁니다. Claude로 계획을 세운 뒤 Codex에 구현을 넘길 때 이 기능을 씁니다.
- `/paseo-loop` — 명확한 완료 조건을 기준으로 에이전트를 반복 실행합니다(Ralph loop). 검증 에이전트를 추가할 수도 있습니다.
- `/paseo-advisor` — 작업 자체를 넘기지 않고, 에이전트 하나를 조언자로 띄워 두 번째 의견을 받습니다.
- `/paseo-committee` — 서로 다른 관점의 에이전트 두 개로 위원회를 구성해, 한 발 물러나 근본 원인을 분석하고 계획을 세웁니다.
-1
View File
@@ -152,7 +152,6 @@ npx skills add getpaseo/paseo
Then use them in any agent conversation:
- `/paseo-handoff` — hand off work between agents. I use this to plan with Claude and then handoff to Codex to implement.
- `/paseo-loop` — loop an agent against clear acceptance criteria (aka Ralph loops), optionally with a verifier.
- `/paseo-advisor` — spin up a single agent as an advisor for a second opinion, without delegating the work itself.
- `/paseo-committee` — form a committee of two contrasting agents to step back, do root cause analysis, and produce a plan.
-1
View File
@@ -118,7 +118,6 @@ npx skills add getpaseo/paseo
然后在任意 agent 对话中使用:
- `/paseo-handoff` — 在 agents 之间交接工作。我会用它先和 Claude 规划,再交给 Codex 实现。
- `/paseo-loop` — 让 agent 按明确验收标准循环工作(也叫 Ralph loops),也可以加 verifier。
- `/paseo-advisor` — 启动单个 agent 作为 advisor,提供第二意见,但不把工作委托出去。
- `/paseo-committee` — 组建两个风格互补的 agents,让它们后退一步做根因分析并产出计划。
+3
View File
@@ -49,6 +49,9 @@ Runtime ownership is resolved from explicit workspace ID and caller context, nev
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.
The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status.
A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown.
## Provider-managed child agents
+1 -7
View File
@@ -76,8 +76,6 @@ not retain non-Git directories.
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
| `server/schedule/` | Cron-based scheduled agents |
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
### `packages/protocol` — Wire schemas and shared protocol types
@@ -119,13 +117,11 @@ Commander.js CLI with Docker-style commands. Common agent operations are also ex
- `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode`
- `paseo daemon start/stop/restart/status/pair/set-password`
- `paseo chat ls/create/inspect/post/read/wait/delete`
- `paseo terminal ls/create/capture/send-keys/kill`
- `paseo script ls/start/stop`
- `paseo loop run/ls/inspect/logs/stop`
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
- `paseo heartbeat create/update/delete`
- `paseo workspace create/ls/archive`
- `paseo workspace create/ls/rename/archive`
- `paseo permit allow/deny/ls`
- `paseo provider ls/models`
- hidden legacy `paseo worktree create/ls/archive` compatibility alias
@@ -384,9 +380,7 @@ $PASEO_HOME/
├── projects/projects.json # Project registry
├── projects/workspaces.json # Workspace registry
├── projects/icons/ # Custom project icon images
├── chat/ # Chat rooms
├── schedules/ # Scheduled-agent definitions and runs
├── loops/ # Loop runs and logs
├── config.json # Daemon config (mutable)
├── daemon-keypair.json # Daemon identity for relay/E2EE
├── push-tokens.json # Mobile push tokens
+2
View File
@@ -245,6 +245,8 @@ requires_openai_auth = false
You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment.
"Profile" here means a provider alias, and it is not an **Agent profile** — that is a named bundle of provider, model, mode, thinking option and features, stored under `daemon.agentProfiles`. See [glossary.md](glossary.md) for all four senses of the word.
Example: two different Anthropic accounts as separate profiles:
```json
+22 -135
View File
@@ -54,10 +54,6 @@ $PASEO_HOME/
│ └── {agentId}.json # One file per agent
├── schedules/
│ └── {scheduleId}.json # One file per schedule
├── chat/
│ └── rooms.json # All rooms + messages
├── loops/
│ └── loops.json # All loop records
├── projects/
│ ├── projects.json # Project registry
│ ├── workspaces.json # Workspace registry
@@ -100,7 +96,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
| `internal` | `boolean?` | Whether this is a system-internal agent |
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
### Nested: SerializableConfig
@@ -189,6 +185,8 @@ Single file, validated with `PersistedConfigSchema`.
mcp: { enabled: boolean, injectIntoAgents: boolean },
git: { maxProcessesPerSecond: number, maxProcessConcurrency: number },
appendSystemPrompt: string, // appended to supported provider system/developer prompts
terminalProfiles: TerminalProfile[], // named shell commands; omitted means DEFAULT_TERMINAL_PROFILES
agentProfiles: AgentProfile[], // named agent launch bundles; omitted means none
cors: { allowedOrigins: string[] },
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean }, // new homes materialize enabled: false
auth: { password: string } // bcrypt hash, optional
@@ -235,6 +233,21 @@ Single file, validated with `PersistedConfigSchema`.
All fields are optional with sensible defaults.
### Profile lists
`terminalProfiles` and `agentProfiles` are both whole-list fields: a config patch replaces the
array, never merges entries, so a client sends the complete next list on every add, edit, reorder
and remove. List order is the display order.
Absent and empty mean different things for terminal profiles — omitting the key falls back to
`DEFAULT_TERMINAL_PROFILES`, while `[]` means the user removed them all. Agent profiles have no
defaults, so both mean none.
`PersistedConfigSchema` parses strictly, so a daemon that predates a field drops it on write
rather than storing something it cannot describe. That is why the client gates the agent profiles
UI on `server_info.features.agentProfiles` instead of letting a save appear to succeed against an
older daemon.
### Git process limits
Git process limits are global to one daemon. The start-rate limit defaults to `64` processes per
@@ -358,133 +371,7 @@ One file per schedule. ID is 8 hex characters.
---
## 4. Chat
**Path:** `$PASEO_HOME/chat/rooms.json`
Single file containing all rooms and messages.
```json
{
"rooms": [ ... ],
"messages": [ ... ]
}
```
### ChatRoom
| Field | Type | Description |
| ----------- | ------------------- | ----------------------------------- |
| `id` | `string` (UUID) | |
| `name` | `string` | Unique room name (case-insensitive) |
| `purpose` | `string?` | Room description |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | Updated on each new message |
### ChatMessage
| Field | Type | Description |
| ------------------ | ------------------- | ----------------------------------- |
| `id` | `string` (UUID) | |
| `roomId` | `string` | FK to ChatRoom.id |
| `authorAgentId` | `string` | Agent ID of the author |
| `body` | `string` | Message text (supports `@mentions`) |
| `replyToMessageId` | `string?` | FK to another ChatMessage.id |
| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs |
| `createdAt` | `string` (ISO 8601) | |
---
## 5. Loop
**Path:** `$PASEO_HOME/loops/loops.json`
Single file containing an array of all loop records. Writes are direct (not atomic) and serialized through an in-memory queue. On daemon startup any record with `status: "running"` is recovered as `"stopped"` with an interruption log entry.
| Field | Type | Description |
| ----------------------- | --------------------------------------------------- | ------------------------------------------ |
| `id` | `string` | 8-char UUID prefix |
| `name` | `string?` | Human-readable name |
| `prompt` | `string` | Worker prompt |
| `cwd` | `string` | Working directory |
| `provider` | `string` | Default provider |
| `model` | `string?` | Default model |
| `modeId` | `string?` | Default mode ID |
| `workerProvider` | `string?` | Override provider for workers |
| `workerModel` | `string?` | Override model for workers |
| `verifierProvider` | `string?` | Override provider for verifiers |
| `verifierModel` | `string?` | Override model for verifiers |
| `verifierModeId` | `string?` | Override mode ID for verifiers |
| `verifyPrompt` | `string?` | LLM verification prompt |
| `verifyChecks` | `string[]` | Shell commands to run as checks |
| `archive` | `boolean` | Whether to archive worker agents after use |
| `sleepMs` | `number` | Delay between iterations (ms) |
| `maxIterations` | `number?` | Cap on iterations |
| `maxTimeMs` | `number?` | Total time budget (ms) |
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `startedAt` | `string` (ISO 8601) | |
| `completedAt` | `string?` (ISO 8601) | |
| `stopRequestedAt` | `string?` (ISO 8601) | |
| `iterations` | `LoopIteration[]` | |
| `logs` | `LoopLogEntry[]` | |
| `nextLogSeq` | `number` | Monotonic log sequence counter |
| `activeIteration` | `number?` | Currently executing iteration index |
| `activeWorkerAgentId` | `string?` | Currently running worker agent |
| `activeVerifierAgentId` | `string?` | Currently running verifier agent |
### Nested: LoopIteration
| Field | Type | Description |
| ------------------- | --------------------------------------------------- | ------------------------ |
| `index` | `number` | 1-based iteration index |
| `workerAgentId` | `string?` | Agent ID of the worker |
| `workerStartedAt` | `string` (ISO 8601) | |
| `workerCompletedAt` | `string?` (ISO 8601) | |
| `verifierAgentId` | `string?` | Agent ID of the verifier |
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | |
| `failureReason` | `string?` | |
| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results |
| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result |
### Nested: LoopLogEntry
| Field | Type |
| ----------- | ---------------------------------------------------- |
| `seq` | `number` (monotonic) |
| `timestamp` | `string` (ISO 8601) |
| `iteration` | `number?` |
| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` |
| `level` | `"info" \| "error"` |
| `text` | `string` |
### Nested: LoopVerifyCheckResult
| Field | Type |
| ------------- | ------------------- |
| `command` | `string` |
| `exitCode` | `number` |
| `passed` | `boolean` |
| `stdout` | `string` |
| `stderr` | `string` |
| `startedAt` | `string` (ISO 8601) |
| `completedAt` | `string` (ISO 8601) |
### Nested: LoopVerifyPromptResult
| Field | Type |
| ----------------- | ------------------- |
| `passed` | `boolean` |
| `reason` | `string` |
| `verifierAgentId` | `string?` |
| `startedAt` | `string` (ISO 8601) |
| `completedAt` | `string` (ISO 8601) |
---
## 6. Project Registry
## 4. Project Registry
**Path:** `$PASEO_HOME/projects/projects.json`
@@ -519,7 +406,7 @@ workspace together with its owning project.
---
## 7. Workspace Registry
## 5. Workspace Registry
**Path:** `$PASEO_HOME/projects/workspaces.json`
@@ -553,7 +440,7 @@ than treating it as valid.
---
## 8. Push Token Store
## 6. Push Token Store
**Path:** `$PASEO_HOME/push-tokens.json`
@@ -567,7 +454,7 @@ Simple set of Expo push notification tokens. Loaded with permissive parsing (fil
---
## 9. Daemon meta files
## 7. Daemon meta files
These small files are not validated as full Zod schemas but are persisted under `$PASEO_HOME` for daemon identity and runtime coordination.
+16 -8
View File
@@ -23,6 +23,10 @@ Desktop dev launches its desktop-managed daemon with `PASEO_NODE_ENV=development
so development-only providers such as Mock Load Test are available. Packaged
desktop launches always force the daemon to production mode.
The web and desktop dev launchers pass the current Git branch to Metro as
`EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL`. The expanded desktop sidebar shows it in
the titlebar row. Production builds leave the variable unset and show no label.
`npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state.
## Nix desktop package
@@ -127,9 +131,11 @@ 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.
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.
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.
With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen
transition, and 751-pixel settings split with:
@@ -291,7 +297,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
@@ -302,9 +310,9 @@ Lifecycle commands run in the worktree through a stable script shell: `bash`
resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on
Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
login and interactive shell startup files are not loaded, and Bash's `BASH_ENV`
hook is unset. Daemon-run loop verify checks and ACP single-string terminal
commands use the same non-login Bash behavior on macOS/Linux, but preserve their
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
hook is unset. ACP single-string terminal commands use the same non-login Bash
behavior on macOS/Linux, but preserve their existing `cmd.exe /c` string semantics
on Windows. Service scripts are separate:
they launch in a terminal and receive the service environment described below.
Because the shell differs per platform, a lifecycle command that must run
@@ -456,7 +464,7 @@ install.
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
Canonical automation uses `paseo workspace create/ls/rename/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
```bash
npm run cli -- ls -a -g # List all agents globally
+4 -2
View File
@@ -24,14 +24,16 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing. Three other things are called "profile" in this codebase, so always qualify: **Agent profile**, **Terminal profile**, and the provider aliases in [custom-providers.md](custom-providers.md). Bare "profile" is forbidden in UI copy.
- **Agent profile** — A named launch bundle: provider, model, mode, thinking option, feature values, and free-text notes. Applying one writes those values into the **Agent controls** and is then forgotten — there is no selected profile, no default profile, and no drift tracking. The notes are written for orchestrating agents and are what the `list_profiles` MCP tool surfaces. Stored host-wide in daemon config as `agentProfiles`. UI: "Agent profiles". Code: `AgentProfile` (`packages/protocol/src/messages.ts:161`). Carries no system prompt, because a per-agent system prompt can only be set at creation. Forbidden: "Favourite", which this replaced.
- **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent.
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
- **Workspace script** — A named command a workspace can run, declared in `paseo.json`. Two types: `service` and `script` (a command that runs and finishes). UI: "Scripts" for the panel and the menu, both of which list either type. Code: `WorkspaceScriptPayload` (`packages/protocol/src/messages.ts:3126`). Don't confuse with **Service**, which is one of the two types rather than a synonym for the pair.
- **Service** — A workspace script of type `service`: long-running, may bind a port, and carries `health` of `healthy | unhealthy | null` once the daemon has observed it. The only type a sidebar workspace row reports, because a running plain script says nothing about whether the workspace is up; the row names the service and turns danger when its health fails, and where several run the failing one takes the row's single slot. UI: "Services" in the sidebar's display preferences (**Show → Services**). Code: `selectWorkspaceServiceSummary` (`packages/app/src/components/sidebar/workspace-meta-row/service-summary.ts`), `WorkspaceScriptHealthSchema` (`packages/protocol/src/messages.ts:3124`). Forbidden: "Service" as a label for the Scripts panel, which lists both types.
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent).
- **Heartbeat** — Ephemeral cron prompt sent back into the same agent/conversation. Agent surfaces expose create, update cron, and delete only. Use for reminders and babysitting where status should return inline.
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
- **Attachment** — External or local context bound to an agent prompt: forge issue/change request, review context, uploaded file, text, or image. UI: "Attach issue or PR/MR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
+1 -1
View File
@@ -12,7 +12,7 @@ The development workflow is shifting from manually editing files to orchestratin
Freedom and flexibility. Every design decision follows from this:
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor-lock in.
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor lock-in.
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
- **Respectful** - No telemetry, no forced cloud, no forced accounts
+2 -5
View File
@@ -37,11 +37,6 @@ Each provider definition owns its option schema and exact MCP preapproval mappin
must fail closed for Hub unattended execution until it can approve one exact injected MCP server
and tool identity without approving native tools.
Codex **Full Access** sends `approvalPolicy: "never"` and `danger-full-access` to the app server.
If a permission-only MCP form still reaches the provider, Paseo accepts it when its schema has no
properties. Forms with fields and URL/auth flows are declined because Paseo cannot supply user data
or credentials. Restricted Codex modes keep their existing approval behavior.
## Two Integration Patterns
### ACP (Agent Client Protocol) -- recommended
@@ -90,6 +85,8 @@ Rewind accepts the canonical wire `messageId` and resolves it to the provider id
Submitted user-message wire items carry the same Paseo ID in `messageId` and `clientMessageId`. Provider adapters attach `clientMessageId` only to the echo for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID.
Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state.
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
+3 -32
View File
@@ -19,23 +19,22 @@ The first carves shipped as **deep modules with a narrow Host seam**, not the `d
- **#1640 — VoiceSession** (`session/voice/voice-session.ts`, seam `VoiceSessionHost`): the STT/TTS/dictation/turn-detection subsystem. _(Originally landed at `server/voice/`; relocated under `session/` so all session subsystems share one home.)_
- **#1644 — CheckoutSession, read side** (`session/checkout/checkout-session.ts`, seam `CheckoutSessionHost`, port `CheckoutDiffSubscriber`): status, branch validate/suggest, diff subscribe/unsubscribe, manual refresh. The workspace-git observer already delegates `emitStatusUpdate`/`scheduleDiffRefresh` to it.
- **ScheduleSession** (`session/schedule/schedule-session.ts`, seam `ScheduleSessionHost`): scheduled-agent request handling.
**Next carve — CheckoutSession mutation side (Slice 4 below).** The 17 checkout _write_ handlers still inline in `session.ts` (`dispatchCheckoutMessage`, ~2010) — branch switch/rename, commit, merge, merge-from-base, pull, push, PR create/merge, github set-auto-merge/get-check-details, PR status, PR timeline, github search, stash save/pop/list — move into the existing `session/checkout/checkout-session.ts` behind `CheckoutSessionHost`. The Slice-3 observer entanglement the table fears is already resolved: #1644 moved the status/diff read side into CheckoutSession, so the workspace observer delegates today and this no longer blocks on the WorkspaceController split.
### Why this is safe at the dispatch seam (verified)
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined` **Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. Two quirks preserved verbatim: schedule/\* is reached via the chat dispatcher's OWN `default` arm (2183), not the top-level `??`; and `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
`dispatchInboundMessage` builds `a() ?? b() ?? ... ?? dispatchMiscMessage()` and short-circuits on the first non-`undefined` **Promise object** (not its resolved value). Message-type spaces are **disjoint** (no duplicate `case` labels across switches), so at most one dispatcher matches any message — collapsing to delegation cannot change which handler runs. `dispatchTerminalMessage` (2150-2153) already proves this. `start_workspace_script_request` (a workspace type) is special-cased before terminal delegation (2150).
Rejected alternatives: **feature-module** (free functions + wide context bag) cannot own the live state machines (workspaceUpdatesSubscription, agentUpdatesSubscription, ~25 voice fields) and adds a competing idiom; **mixin-composition** preserves the shared-`this` god object verbatim and requires widening ~325 private fields to protected.
## Slice ordering (least-coupled first)
The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verification: `emitCheckoutStatusUpdate` is called from exactly ONE site (session.ts:4915), inside the workspace-owned `syncWorkspaceGitObserver` callback that ALSO fires workspace effects over shared watch-target maps. Extracting checkout first forces splitting the hardest workspace/git seam before workspace is touched. The strictly safer first cuts are **chat-schedule-loop** (only knot: `handleChatPostRequest`; touches no shared observer/git/voice state) and **provider-catalog** (one shared collaborator + injected predicates).
The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verification: `emitCheckoutStatusUpdate` is called from exactly ONE site (session.ts:4915), inside the workspace-owned `syncWorkspaceGitObserver` callback that ALSO fires workspace effects over shared watch-target maps. Extracting checkout first forces splitting the hardest workspace/git seam before workspace is touched. The safer first cut is **provider-catalog** (one shared collaborator + injected predicates).
| # | Slice | Effort | Risk |
| --- | ----------------------------------------------------------------------------- | ------ | ------ |
| 0 | Test net + disjointness tripwire (no extraction) | M | low |
| 1 | ChatScheduleLoopController — **STOP FOR REVIEW after green** | M | low |
| 2 | ProviderCatalogController | M | medium |
| 3 | Split shared workspace-git observer + agent-subscribe fan-out (no controller) | M | high |
| 4 | GitCheckoutController | L | medium |
@@ -47,34 +46,6 @@ The task recommended **git/checkout as the first slice — OVERRIDDEN.** Verific
---
## Slice 0 — Test net + disjointness tripwire (prerequisite)
No production code moves. Add `session.dispatch-seam.test.ts`. This is the gate the whole plan rests on, because chat/schedule/loop have **zero** handleMessage coverage today (verified).
Write RED-then-GREEN against the **current in-place** Session:
- `chat/post` happy path (asserts `chat/post` response emitted) + fanout-limit error path (asserts the `chat/post` error envelope, NOT a bubbled `rpc_error`).
- one `schedule/*` and one `loop/*` round-trip.
- a handler that throws **synchronously** emits `rpc_error{code:"handler_error"}` + an `activity_log` error frame.
- a handler that **rejects async** emits the SAME pair.
- a table-driven assertion that the union of all controllers' owned-type `ReadonlySet`s is pairwise disjoint and covers the dispatched `SessionInboundMessage` union (grows as controllers land).
**Tests:** `session.dispatch-seam.test.ts`, `session.test.ts`.
---
## Slice 1 — ChatScheduleLoopController ← STOP FOR HUMAN REVIEW after this ships green
**Move:** all 21 handlers (`handleChat*` ×7, `handleSchedule*` ×9, `handleLoop*` ×5), the three rpc-error emitters (`emitChatRpcError`/`emitScheduleRpcError`/`emitLoopRpcError` — **kept separate, not merged**), `toScheduleSummary` → `packages/server/src/server/chat/chat-schedule-loop-controller.ts`. Collapse `dispatchChatScheduleLoopMessage` + `dispatchScheduleMessage` to `return this.chatScheduleLoopController.dispatch(msg)`.
**SessionContext surface:** `emit`, `sessionLogger`, `clientId` (authorAgentId fallback), `chatService`, `scheduleService`, `loopService`, and a narrow agent-control port `{ listAgents, resolveAgentIdentifier, agentStorage.list }` for `handleChatPostRequest` mention fanout.
**Owned-type set MUST include all 7 `chat/*` + 5 `loop/*` + 9 `schedule/*` types** — schedule/\* is currently routed via the chat dispatcher's own `default` arm, so it must stay inside this one controller, or schedule requests silently no-op.
**Behavior note:** least-coupled domain. Move the three rpc-error emitters verbatim (they differ in default code + the `ChatServiceError` branch). **Tests:** `session.dispatch-seam.test.ts`, `loop-service.test.ts`, `session.test.ts`.
---
## Slice 2 — ProviderCatalogController
**Move:** 7 provider handlers + `emitProviderDisabledResponse` + `getProviderSnapshotEntryForRead` → `packages/server/src/server/provider/provider-catalog-controller.ts`. Move the `providers_snapshot_update` PUSH wiring (1235-1254) into the controller's `start()`/`dispose()`. Collapse `dispatchProviderMessage`.
+1 -1
View File
@@ -1 +1 @@
sha256-tB0WZG6F6VFMS5zFqo73giSAn7SqfkJISF5fMjDioJQ=
sha256-L73o8u6OsY3SsA5t2s5mWsMsMNc3qr5bCE4cd1NpbgI=
+1060 -370
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,6 +1,7 @@
const fs = require("node:fs");
const path = require("node:path");
const pkg = require("./package.json");
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
const withAndroidProfileable = require("./plugins/with-android-profileable");
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
const { getNativeReleaseVersion } = require("./native-release-version");
@@ -138,6 +139,7 @@ export default {
},
plugins: [
"expo-router",
[withAndroidAsyncStorageSize, 10],
...buildProfile.cameraPlugins,
[
"expo-splash-screen",
@@ -0,0 +1,126 @@
import { test } from "../support/fixtures";
import {
applyProfileFromPicker,
closeModelPicker,
drillIntoProvider,
expectComposerDoesNotName,
expectAgentProfilesEmptyPrompt,
expectProfileEditTooltip,
expectComposerMode,
expectComposerModel,
expectModelRowSelected,
expectNothingSelectedInPickerRoot,
expectProfileEditIsPencilOnly,
expectProfilePinnedAboveProviders,
expectProfileVisibleForProvider,
openModelPicker,
openAgentProfilesFromEmptyPrompt,
seedAgentProfiles,
} from "../support/helpers/agent-profiles";
import { expectWorkspaceAgentConfiguration } from "../support/helpers/command-center-agent-controls";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const PROFILE = {
id: "agent_profile_e2e_ui_work",
name: "UI work",
icon: "🎨",
provider: "mock",
model: "one-minute-stream",
modeId: "approval-test",
notes: "Use for UI work.",
};
const PROFILE_SUMMARY = "Mock Load Test · One minute stream · Approval test";
test.describe("Agent profiles in the model picker", () => {
test("an empty host still exposes agent profile settings from the picker", async ({ page }) => {
const seed = await seedAgentProfiles([]);
const workspace = await seedMockAgentWorkspace({
repoPrefix: "agent-profiles-empty-",
title: "Agent profiles empty",
});
try {
await openAgentRoute(page, workspace);
await expectComposerVisible(page);
await openModelPicker(page);
await expectAgentProfilesEmptyPrompt(page);
await openAgentProfilesFromEmptyPrompt(page);
} finally {
await workspace.cleanup();
await seed.restore();
}
});
test("applying a pinned profile materializes it into the composer and is then forgotten", async ({
page,
}) => {
const seed = await seedAgentProfiles([PROFILE]);
// A live agent is one provider's process, so the profile has to name that
// same provider or the picker will not offer it at all.
const workspace = await seedMockAgentWorkspace({
repoPrefix: "agent-profiles-picker-",
title: "Agent profiles picker",
model: "ten-second-stream",
modeId: "load-test",
});
try {
await test.step("the agent starts on its seeded model and mode", async () => {
await openAgentRoute(page, workspace);
await expectComposerVisible(page);
await expectComposerModel(page, "Ten second stream");
await expectComposerMode(page, "Load test");
});
await test.step("the profile is pinned above the provider list", async () => {
await openModelPicker(page);
await expectProfilePinnedAboveProviders(page, {
name: PROFILE.name,
summary: PROFILE_SUMMARY,
});
await expectProfileEditIsPencilOnly(page);
await expectProfileEditTooltip(page);
});
await test.step("applying it writes its model and mode into the composer", async () => {
await applyProfileFromPicker(page, PROFILE.name);
await expectComposerModel(page, "One minute stream");
await expectComposerMode(page, "Approval test");
await expectWorkspaceAgentConfiguration(workspace, {
id: workspace.agentId,
provider: "mock",
model: "one-minute-stream",
modeId: "approval-test",
});
});
await test.step("the composer names the model, never the profile", async () => {
await expectComposerDoesNotName(page, PROFILE.name);
});
await test.step("reopening the picker shows no profile marked as selected", async () => {
await openModelPicker(page);
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,
});
await expectModelRowSelected(page, { provider: "mock", modelId: "one-minute-stream" });
await closeModelPicker(page);
});
} finally {
await workspace.cleanup();
await seed.restore();
}
});
});
@@ -0,0 +1,128 @@
import { test } from "../support/fixtures";
import {
createAgentProfile,
createAgentProfileFromEmptyState,
editAgentProfile,
expectAgentProfile,
expectAgentProfileForm,
expectAgentProfileOrder,
expectAgentProfileTagsGone,
expectHostAgentProfiles,
expectNoAgentProfiles,
moveAgentProfileUp,
openAgentProfileSettings,
removeAgentProfile,
seedAgentProfiles,
stageLegacyFavoritesForHostMigration,
} from "../support/helpers/agent-profiles";
const MOCK_PROVIDER_LABEL = "Mock Load Test";
test.describe("Agent profiles settings", () => {
test("legacy model favourites migrate into provider-and-model-only host profiles", async ({
page,
}) => {
const seed = await seedAgentProfiles([]);
const migratedProfile = {
id: "legacy_favorite:mock:one-minute-stream",
name: "One minute stream",
provider: "mock",
model: "one-minute-stream",
};
try {
await stageLegacyFavoritesForHostMigration(page, [
{ provider: "mock", modelId: "one-minute-stream" },
]);
await expectHostAgentProfiles([migratedProfile]);
await openAgentProfileSettings(page);
await expectAgentProfile(page, {
name: migratedProfile.name,
tags: [MOCK_PROVIDER_LABEL, "One minute stream"],
});
} finally {
await seed.restore();
}
});
test("host owner creates, edits, reorders and removes agent profiles", async ({ page }) => {
// The journey owns the list, so it starts from an empty one whatever the
// worker ran before, and hands it back untouched.
const seed = await seedAgentProfiles([]);
try {
await test.step("the section starts empty", async () => {
await openAgentProfileSettings(page);
await expectNoAgentProfiles(page);
});
await test.step("create a profile from the modal", async () => {
await createAgentProfileFromEmptyState(page, {
name: "UI work",
provider: MOCK_PROVIDER_LABEL,
model: "Ten second stream",
thinking: "High",
mode: "Approval test",
notes: "Use for UI work — components, layout and design tokens.",
});
await expectAgentProfile(page, {
name: "UI work",
tags: [MOCK_PROVIDER_LABEL, "Ten second stream", "Approval test", "High"],
notes: "Use for UI work — components, layout and design tokens.",
});
});
await test.step("editing to a model without thinking levels drops the level", async () => {
await editAgentProfile(page, "UI work", {
model: "One minute stream",
notes: "Now for quick checks.",
});
await expectAgentProfile(page, {
name: "UI work",
tags: [MOCK_PROVIDER_LABEL, "One minute stream", "Approval test"],
notes: "Now for quick checks.",
});
await expectAgentProfileTagsGone(page, {
name: "UI work",
tags: ["Ten second stream", "High"],
});
});
await test.step("the edit form reopens on what was stored", async () => {
await expectAgentProfileForm(page, "UI work", {
provider: MOCK_PROVIDER_LABEL,
model: "One minute stream",
mode: "Approval test",
notes: "Now for quick checks.",
});
});
await test.step("a second profile lands below the first", async () => {
await createAgentProfile(page, {
name: "Deep review",
provider: MOCK_PROVIDER_LABEL,
model: "Five minute stream",
});
await expectAgentProfileOrder(page, ["UI work", "Deep review"]);
});
await test.step("reorder puts the second profile on top", async () => {
await moveAgentProfileUp(page, "Deep review");
await expectAgentProfileOrder(page, ["Deep review", "UI work"]);
});
await test.step("removing a profile confirms by name", async () => {
await removeAgentProfile(page, "UI work");
await expectAgentProfileOrder(page, ["Deep review"]);
});
await test.step("removing the last profile returns the empty state", async () => {
await removeAgentProfile(page, "Deep review");
await expectNoAgentProfiles(page);
});
} finally {
await seed.restore();
}
});
});
@@ -1,4 +1,7 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { openSettingsSection } from "../support/helpers/settings";
test("shows Pure black in the appearance picker", async ({ page }, testInfo) => {
@@ -15,6 +18,33 @@ test("shows Pure black in the appearance picker", async ({ page }, testInfo) =>
});
});
test("keeps the selected workspace visible in Pure black", async ({ page }, testInfo) => {
const workspace = await seedWorkspace({
repoPrefix: "pure-black-selected-workspace-",
title: "Selected workspace",
});
try {
await page.addInitScript(() => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "pureBlack" }));
});
await gotoAppShell(page);
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await expect(row).toHaveAttribute("aria-selected", "true");
await expect(row).toHaveCSS("background-color", "rgb(22, 22, 22)");
await page.screenshot({
path: testInfo.outputPath("pure-black-selected-workspace.png"),
fullPage: true,
});
} finally {
await workspace.cleanup();
}
});
test("applies the interface font size to settings text", async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontSize: 24 }));
+398 -41
View File
@@ -1,8 +1,10 @@
import { unlink, writeFile } from "node:fs/promises";
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";
import { getServerId } from "../support/helpers/server-id";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
@@ -15,6 +17,9 @@ interface DirtyWorkspace {
interface WorkspaceFixtureOptions {
includeDeletedFile?: boolean;
includeNestedFolders?: boolean;
includeRenamedFile?: boolean;
includeUntrackedFile?: boolean;
}
interface CleanupTask {
@@ -23,8 +28,45 @@ interface CleanupTask {
const cleanupTasks: CleanupTask[] = [];
const APP_SETTINGS_KEY = "@paseo:app-settings";
async function failNextDiscardRequest(page: Page): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
const serverSocket = browserSocket.connectToServer();
browserSocket.onMessage((message) => {
if (typeof message === "string") {
const envelope = JSON.parse(message) as {
message?: { type?: string; cwd?: string; requestId?: string };
};
if (envelope.message?.type === "checkout.discard_changes.request") {
browserSocket.send(
JSON.stringify({
type: "session",
message: {
type: "checkout.discard_changes.response",
payload: {
cwd: envelope.message.cwd,
success: false,
error: { code: "UNKNOWN", message: "Injected revert failure" },
requestId: envelope.message.requestId,
},
},
}),
);
return;
}
}
serverSocket.send(message);
});
serverSocket.onMessage((message) => browserSocket.send(message));
});
}
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 {
@@ -209,9 +251,15 @@ test("changes file actions open below the right-click without a reserved kebab",
await openWorkspaceChanges(page, workspace);
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
const deletedFileName = page.getByText("zz-deleted.ts", { exact: true });
await expect(deletedFileName).toHaveCSS("user-select", "none");
await deletedFileName.dblclick();
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
await page.getByTestId("diff-file-1-toggle").click({ button: "right" });
await expect(page.getByText("Copy path")).toBeVisible();
await page.getByText("Copy path", { exact: true }).click({ button: "right" });
await expect(page.getByText("Copy path")).toBeVisible();
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
await page.keyboard.press("Escape");
@@ -230,6 +278,183 @@ test("changes file actions open below the right-click without a reserved kebab",
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
});
test("changes context menus duplicate files and folders", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await page.getByTestId("diff-file-0-duplicate").click();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts"), "utf8"))
.toBe(AFTER);
await page.getByTestId("changes-toggle-view-mode").click();
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
await page.getByTestId("diff-folder-src-duplicate").click();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "src copy/use-mounted-tab-set.ts"), "utf8"))
.toBe(AFTER);
});
test("changes context menu recursively collapses descendant folders", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
await page.getByTestId("diff-folder-src-collapse-folder").click();
await expect(page.getByTestId("diff-folder-src/zz-folder")).toHaveCount(0);
await page.getByTestId("diff-folder-src-toggle").click();
await expect(page.getByTestId("diff-folder-src/zz-folder")).toBeVisible();
await expect(page.getByText("root.ts", { exact: true })).toHaveCount(0);
await page.getByTestId("diff-folder-src/zz-folder-toggle").click();
await expect(page.getByText("root.ts", { exact: true })).toBeVisible();
await expect(page.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible();
await expect(page.getByText("changed.ts", { exact: true })).toHaveCount(0);
await page.getByTestId("diff-folder-src/zz-folder/nested-toggle").click();
await expect(page.getByText("changed.ts", { exact: true })).toBeVisible();
});
test("changes context menus expose folder revert and restore a file after confirmation", async ({
page,
}) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-view-mode").click();
await page.getByTestId("diff-folder-src-toggle").click({ button: "right" });
const folderRevert = page.getByTestId("diff-folder-src-revert");
await expect(folderRevert).toBeVisible();
const revertLabelColor = await folderRevert
.getByText("Discard changes", { exact: true })
.evaluate((element) => getComputedStyle(element).color);
await expect(folderRevert.locator("svg")).toHaveCSS("stroke", revertLabelColor);
await page.keyboard.press("Escape");
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
const cancelledConfirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => {
const message = dialog.message();
await dialog.dismiss();
resolve(message);
});
});
await page.getByTestId("diff-file-0-revert").click();
expect(await cancelledConfirmation).toContain("src/use-mounted-tab-set.ts");
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
.toBe(AFTER);
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
const confirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => {
const message = dialog.message();
await dialog.accept();
resolve(message);
});
});
await page.getByTestId("diff-file-0-revert").click();
expect(await confirmation).toContain("src/use-mounted-tab-set.ts");
await expect(page.getByTestId("diff-file-0")).toHaveCount(0, { timeout: 30_000 });
await expect
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
.toBe(BEFORE);
});
test("discarding a staged rename restores its source path", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeRenamedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const renamedToggle = page
.getByTestId(/^diff-file-\d+-toggle$/)
.filter({ hasText: "zz-renamed.ts" });
const toggleTestId = await renamedToggle.getAttribute("data-testid");
expect(toggleTestId).not.toBeNull();
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
await renamedToggle.click({ button: "right" });
const confirmation = new Promise<void>((resolve) => {
page.once("dialog", async (dialog) => {
await dialog.accept();
resolve();
});
});
await page.getByTestId(`${rowTestId}-revert`).click();
await confirmation;
await expect(page.getByText("zz-renamed.ts", { exact: true })).toHaveCount(0, {
timeout: 30_000,
});
await expect
.poll(() => readFile(path.join(workspace.repoPath, "src/rename-source.ts"), "utf8"))
.toBe("export const renamed = true;\n");
});
test("discarding an untracked file removes it from the working tree", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeUntrackedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
const untrackedToggle = page
.getByTestId(/^diff-file-\d+-toggle$/)
.filter({ hasText: "zz-untracked.txt" });
const toggleTestId = await untrackedToggle.getAttribute("data-testid");
expect(toggleTestId).not.toBeNull();
const rowTestId = toggleTestId!.slice(0, -"-toggle".length);
await untrackedToggle.click({ button: "right" });
const confirmation = new Promise<void>((resolve) => {
page.once("dialog", async (dialog) => {
await dialog.accept();
resolve();
});
});
await page.getByTestId(`${rowTestId}-revert`).click();
await confirmation;
await expect(page.getByText("zz-untracked.txt", { exact: true })).toHaveCount(0, {
timeout: 30_000,
});
await expect(
readFile(path.join(workspace.repoPath, "zz-untracked.txt"), "utf8"),
).rejects.toThrow();
});
test("shows a revert error returned by the daemon", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await failNextDiscardRequest(page);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
const confirmation = new Promise<void>((resolve) => {
page.once("dialog", async (dialog) => {
await dialog.accept();
resolve();
});
});
await page.getByTestId("diff-file-0-revert").click();
await confirmation;
await expect(page.getByText("Injected revert failure", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8"))
.toBe(AFTER);
});
test("Changes switches between inline and full-tab navigation", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
@@ -244,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();
@@ -265,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);
@@ -295,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 }) => {
@@ -327,15 +546,44 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await scrollToLowerUnwrappedDiffRows(page);
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
await expect(page.getByTestId("diff-folder-src").getByText("src", { exact: true })).toHaveCSS(
"user-select",
"none",
);
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
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 folderLabelBounds = await page
.getByTestId("diff-folder-src")
.getByText("src", { exact: true })
.boundingBox();
const fileLabelBounds = await page
.getByTestId("diff-file-0")
.getByText("use-mounted-tab-set.ts", { exact: true })
.boundingBox();
expect(folderLabelBounds).not.toBeNull();
expect(fileLabelBounds).not.toBeNull();
expect(fileLabelBounds!.x - folderLabelBounds!.x).toBeCloseTo(14, 0);
const folderToggle = page.getByTestId("diff-folder-src-toggle");
await folderToggle.click();
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
await folderToggle.click();
await expect(folderToggle).toHaveAttribute("aria-selected", "true");
await expect(page.getByTestId("diff-file-0")).toBeVisible();
const fileToggle = page.getByTestId("diff-file-0-toggle");
await fileToggle.click({ button: "right" });
await expect(fileToggle).toHaveAttribute("aria-selected", "true");
await expect(folderToggle).toHaveAttribute("aria-selected", "false");
await expect(page.getByTestId("diff-file-0-context-menu")).toBeVisible();
await page.keyboard.press("Escape");
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);
@@ -359,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 }) => {
@@ -384,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 }) => {
@@ -432,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]),
@@ -488,6 +827,15 @@ async function createWorkspaceWithMountedTabDiff(
if (options.includeDeletedFile) {
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
}
if (options.includeRenamedFile) {
files.push({ path: "src/rename-source.ts", content: "export const renamed = true;\n" });
}
if (options.includeNestedFolders) {
files.push(
{ path: "src/zz-folder/root.ts", content: "export const root = 1;\n" },
{ path: "src/zz-folder/nested/changed.ts", content: "export const nested = 1;\n" },
);
}
const repo = await createTempGitRepo("changes-pane-", { files });
const client = await connectSeedClient();
cleanupTasks.push({
@@ -498,9 +846,24 @@ async function createWorkspaceWithMountedTabDiff(
});
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
if (options.includeUntrackedFile) {
await writeFile(path.join(repo.path, "zz-untracked.txt"), "remove me\n");
}
if (options.includeDeletedFile) {
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
}
if (options.includeRenamedFile) {
execFileSync("git", ["mv", "src/rename-source.ts", "src/zz-renamed.ts"], {
cwd: repo.path,
});
}
if (options.includeNestedFolders) {
await writeFile(path.join(repo.path, "src/zz-folder/root.ts"), "export const root = 2;\n");
await writeFile(
path.join(repo.path, "src/zz-folder/nested/changed.ts"),
"export const nested = 2;\n",
);
}
const createdWorkspace = await client.createWorkspace({
source: { kind: "directory", path: repo.path },
});
@@ -526,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,
});
@@ -565,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();
}
@@ -0,0 +1,58 @@
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openCommandCenter } from "../support/helpers/command-center";
import { seedWorkspace } from "../support/helpers/seed-client";
const GROUP_BY_STATUS = "Group by status";
const GROUP_BY_PROJECT = "Group by project";
// Result rows carry no testID of their own, so the entries are addressed by their visible label.
async function runGroupingEntry(page: Page, label: string, absent: string): Promise<void> {
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("group");
const entry = panel.getByText(label, { exact: true });
await expect(entry).toBeVisible({ timeout: 30_000 });
// The entry always names the mode you are not in, so its opposite must never be listed.
await expect(panel.getByText(absent, { exact: true })).toHaveCount(0);
await entry.click();
await expect(page.getByTestId("command-center-panel")).not.toBeVisible({ timeout: 30_000 });
}
test.describe("Command center sidebar grouping", () => {
test.describe.configure({ timeout: 120_000 });
test("flips sidebar grouping and persists the choice across a reload", async ({ page }) => {
const seeded = await seedWorkspace({ repoPrefix: "command-center-grouping-" });
try {
await gotoAppShell(page);
const projectList = page.getByTestId("sidebar-project-workspace-list-scroll");
const statusList = page.getByTestId("sidebar-status-list-scroll");
await expect(projectList).toBeVisible({ timeout: 30_000 });
// Query-only: the grouping entry stays out of the default empty-query list.
const panel = await openCommandCenter(page);
await expect(panel.getByText(GROUP_BY_STATUS, { exact: true })).toHaveCount(0);
await expect(panel.getByText(GROUP_BY_PROJECT, { exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(panel).not.toBeVisible({ timeout: 30_000 });
await runGroupingEntry(page, GROUP_BY_STATUS, GROUP_BY_PROJECT);
await expect(statusList).toBeVisible({ timeout: 30_000 });
await expect(projectList).toHaveCount(0);
// The only assertion that catches a flip which never reached persisted storage.
await page.reload();
await expect(statusList).toBeVisible({ timeout: 30_000 });
// Grouping persists, so the run must leave the sidebar back in project mode.
await runGroupingEntry(page, GROUP_BY_PROJECT, GROUP_BY_STATUS);
await expect(projectList).toBeVisible({ timeout: 30_000 });
await expect(statusList).toHaveCount(0);
} finally {
await seeded.cleanup().catch(() => undefined);
}
});
});
@@ -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);
@@ -0,0 +1,417 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { openFileExplorer } from "../support/helpers/file-explorer";
import { gotoWorkspace } from "../support/helpers/launcher";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
let workspace: SeededWorkspace;
async function failNextDeleteRequest(page: Page): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
const serverSocket = browserSocket.connectToServer();
browserSocket.onMessage((message) => {
if (typeof message === "string") {
const envelope = JSON.parse(message) as {
message?: { type?: string; cwd?: string; path?: string; requestId?: string };
};
if (envelope.message?.type === "fs.entry.delete.request") {
browserSocket.send(
JSON.stringify({
type: "session",
message: {
type: "fs.entry.delete.response",
payload: {
cwd: envelope.message.cwd,
path: envelope.message.path,
success: false,
error: "Injected delete failure",
requestId: envelope.message.requestId,
},
},
}),
);
return;
}
}
serverSocket.send(message);
});
serverSocket.onMessage((message) => browserSocket.send(message));
});
}
async function hideContextActionCapabilities(page: Page): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
const serverSocket = browserSocket.connectToServer();
browserSocket.onMessage((message) => serverSocket.send(message));
serverSocket.onMessage((message) => {
if (typeof message !== "string") {
browserSocket.send(message);
return;
}
const envelope = JSON.parse(message) as {
message?: {
type?: string;
payload?: { status?: string; features?: Record<string, unknown> };
};
};
if (
envelope.message?.type === "status" &&
envelope.message.payload?.status === "server_info"
) {
envelope.message.payload.features = {
...envelope.message.payload.features,
fsEntryOps: false,
fsEntryDuplicate: false,
checkoutDiscardChanges: false,
};
}
browserSocket.send(JSON.stringify(envelope));
});
});
}
async function disconnectNextContextAction(page: Page): Promise<{ wait: () => Promise<void> }> {
let resolveRequest: (() => void) | undefined;
const requestSeen = new Promise<void>((resolve) => {
resolveRequest = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => {
const serverSocket = browserSocket.connectToServer();
browserSocket.onMessage((message) => {
if (typeof message === "string") {
const envelope = JSON.parse(message) as { message?: { type?: string } };
const type = envelope.message?.type;
if (type?.startsWith("fs.entry.") || type === "checkout.discard_changes.request") {
resolveRequest?.();
void browserSocket.close({
code: 1008,
reason: "Context action connection failed",
});
return;
}
}
serverSocket.send(message);
});
serverSocket.onMessage((message) => browserSocket.send(message));
});
return { wait: () => requestSeen };
}
test.beforeEach(async () => {
workspace = await seedWorkspace({ repoPrefix: "file-explorer-context-actions-" });
});
test.afterEach(async () => {
await workspace?.cleanup();
});
test("creates, renames, copies, and deletes entries through the file explorer", async ({
context,
page,
}) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
const tree = page.getByTestId("file-explorer-tree-scroll");
const entry = (name: string) => tree.getByText(name, { exact: true }).first();
const nameInput = page.getByTestId("file-explorer-name-input");
await page.getByTestId("files-new-folder").click();
await expect(nameInput).toBeVisible();
await nameInput.press("Tab");
await expect(nameInput).toBeHidden();
await page.getByTestId("files-new-file").click();
await nameInput.fill("README.md");
await nameInput.press("Enter");
await expect(page.getByText('"README.md" already exists', { exact: true })).toBeVisible();
const emptyArea = page.getByTestId("files-empty-area");
await expect(emptyArea).toBeVisible();
const emptyAreaBounds = await emptyArea.boundingBox();
expect(emptyAreaBounds).not.toBeNull();
await emptyArea.click({
button: "right",
position: { x: emptyAreaBounds!.width / 2, y: emptyAreaBounds!.height - 24 },
});
await expect(page.getByTestId("files-empty-area-new-file")).toBeVisible();
await page.getByTestId("files-empty-area-new-folder").click();
await nameInput.fill("folder");
await nameInput.press("Enter");
await expect(entry("folder")).toBeVisible();
await entry("folder").click({ button: "right" });
await page.getByText("New file", { exact: true }).click();
await nameInput.fill("child.txt");
await nameInput.press("Enter");
await expect(entry("child.txt")).toBeVisible();
await entry("folder").click({ button: "right" });
await page.getByText("New folder", { exact: true }).click();
await nameInput.fill("nested");
await nameInput.press("Enter");
await entry("nested").click({ button: "right" });
await page.getByText("New folder", { exact: true }).click();
await nameInput.fill("deep");
await nameInput.press("Enter");
await expect(entry("deep")).toBeVisible();
await entry("folder").click({ button: "right" });
await page.getByText("Collapse folder", { exact: true }).click();
await expect(entry("child.txt")).toBeHidden();
await expect(entry("nested")).toBeHidden();
await expect(entry("deep")).toBeHidden();
await entry("folder").click();
await expect(entry("child.txt")).toBeVisible();
await expect(entry("nested")).toBeVisible();
await expect(entry("deep")).toBeHidden();
await entry("nested").click();
await expect(entry("deep")).toBeVisible();
await page.getByTestId("files-new-file").click();
await nameInput.fill("created.txt");
await nameInput.press("Enter");
await expect(entry("created.txt")).toBeVisible();
await expect(entry("created.txt")).toHaveCSS("user-select", "none");
await entry("created.txt").dblclick();
expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
const folderRow = entry("folder").locator(
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
);
const fileRow = entry("created.txt").locator(
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][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();
expect(collapsedChevronBounds).not.toBeNull();
expect(expandedChevronBounds).not.toBeNull();
const collapsedChevronCenter = collapsedChevronBounds!.x + collapsedChevronBounds!.width / 2;
const expandedChevronCenter = expandedChevronBounds!.x + expandedChevronBounds!.width / 2;
expect(collapsedChevronCenter).toBeCloseTo(expandedChevronCenter, 0);
const folderLabelBounds = await entry("folder").boundingBox();
const fileLabelBounds = await entry("created.txt").boundingBox();
expect(folderLabelBounds).not.toBeNull();
expect(fileLabelBounds).not.toBeNull();
expect(fileLabelBounds!.x).toBeCloseTo(folderLabelBounds!.x, 0);
await folderRow.click();
await expect(folderRow).toHaveAttribute("aria-selected", "true");
await expect(entry("child.txt")).toBeHidden();
await folderRow.click();
await expect(folderRow).toHaveAttribute("aria-selected", "true");
await expect(entry("child.txt")).toBeVisible();
await entry("folder").click({ button: "right" });
await page.getByText("Rename", { exact: true }).click();
await nameInput.fill("renamed-folder");
await nameInput.press("Tab");
await expect(entry("renamed-folder")).toBeVisible();
await expect(entry("folder")).toBeHidden();
await expect(entry("child.txt")).toBeVisible();
const renamedFolderRow = entry("renamed-folder").locator(
"xpath=ancestor::*[starts-with(@data-testid, 'file-explorer-row-')][1]",
);
await expect(renamedFolderRow).toHaveAttribute("aria-selected", "true");
await entry("created.txt").click({ button: "right" });
await expect(fileRow).toHaveAttribute("aria-selected", "true");
await expect(renamedFolderRow).toHaveAttribute("aria-selected", "false");
const menu = page.getByTestId(/file-explorer-row-\d+-context-menu$/);
await expect(menu).toBeVisible();
await page.getByText("Copy path", { exact: true }).click({ button: "right" });
await expect(menu).toBeVisible();
const backdrop = page.getByTestId(/file-explorer-row-\d+-context-menu-backdrop$/);
await backdrop.click({ button: "right" });
await expect(menu).toBeHidden();
await entry("created.txt").click({ button: "right" });
await page.getByText("Rename", { exact: true }).click();
await expect(nameInput).toHaveValue("created.txt");
await nameInput.press("Escape");
await expect(entry("created.txt")).toBeVisible();
await entry("created.txt").click({ button: "right" });
await page.getByText("Rename", { exact: true }).click();
await nameInput.fill("README.md");
await nameInput.press("Enter");
await expect(page.getByText('"README.md" already exists', { exact: true })).toBeVisible();
await expect(entry("created.txt")).toBeVisible();
await entry("created.txt").click({ button: "right" });
await page.getByText("Rename", { exact: true }).click();
await nameInput.fill("renamed.txt");
await nameInput.press("Tab");
await expect(entry("renamed.txt")).toBeVisible();
await expect(entry("created.txt")).toBeHidden();
await entry("renamed.txt").click({ button: "right" });
await page.getByText("Copy relative path", { exact: true }).click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("renamed.txt");
await entry("renamed.txt").click({ button: "right" });
const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/);
const deleteLabelColor = await deleteAction
.getByText("Delete", { exact: true })
.evaluate((element) => getComputedStyle(element).color);
await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor);
const cancelledConfirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => {
const message = dialog.message();
await dialog.dismiss();
resolve(message);
});
});
await page.getByText("Delete", { exact: true }).click();
expect(await cancelledConfirmation).toContain("renamed.txt");
await expect(entry("renamed.txt")).toBeVisible();
await entry("renamed.txt").click({ button: "right" });
const confirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => {
const message = dialog.message();
await dialog.accept();
resolve(message);
});
});
await page.getByText("Delete", { exact: true }).click();
expect(await confirmation).toContain("renamed.txt");
await expect(entry("renamed.txt")).toBeHidden();
await entry("renamed-folder").click({ button: "right" });
const folderConfirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => {
const message = dialog.message();
await dialog.accept();
resolve(message);
});
});
await page.getByText("Delete", { exact: true }).click();
expect(await folderConfirmation).toContain("renamed-folder");
await expect(entry("renamed-folder")).toBeHidden();
await expect(entry("child.txt")).toBeHidden();
});
test("duplicates files and folders with collision-free names", async ({ page }) => {
const sourceFilePath = path.join(workspace.repoPath, "duplicate-source.txt");
const sourceFolderPath = path.join(workspace.repoPath, "duplicate-folder");
await writeFile(sourceFilePath, "duplicate contents", "utf8");
await mkdir(sourceFolderPath);
await writeFile(path.join(sourceFolderPath, "nested.txt"), "nested contents", "utf8");
try {
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
const tree = page.getByTestId("file-explorer-tree-scroll");
const entry = (name: string) => tree.getByText(name, { exact: true }).first();
await entry("duplicate-source.txt").click({ button: "right" });
await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click();
await expect(entry("duplicate-source copy.txt")).toBeVisible();
await expect
.poll(() => readFile(path.join(workspace.repoPath, "duplicate-source copy.txt"), "utf8"))
.toBe("duplicate contents");
await entry("duplicate-source.txt").click({ button: "right" });
await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click();
await expect(entry("duplicate-source copy 2.txt")).toBeVisible();
await entry("duplicate-folder").click({ button: "right" });
await page.getByTestId(/file-explorer-row-\d+-duplicate$/).click();
await expect(entry("duplicate-folder copy")).toBeVisible();
await expect
.poll(() =>
readFile(path.join(workspace.repoPath, "duplicate-folder copy", "nested.txt"), "utf8"),
)
.toBe("nested contents");
} finally {
await Promise.all([
rm(sourceFilePath, { force: true }),
rm(path.join(workspace.repoPath, "duplicate-source copy.txt"), { force: true }),
rm(path.join(workspace.repoPath, "duplicate-source copy 2.txt"), { force: true }),
rm(sourceFolderPath, { recursive: true, force: true }),
rm(path.join(workspace.repoPath, "duplicate-folder copy"), { recursive: true, force: true }),
]);
}
});
test("shows an actionable error when the connection drops during creation", async ({ page }) => {
const disconnect = await disconnectNextContextAction(page);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await page.getByTestId("files-new-file").click();
const nameInput = page.getByTestId("file-explorer-name-input");
await nameInput.fill("disconnected.txt");
await nameInput.press("Enter");
await disconnect.wait();
await expect(page.getByText(/Context action connection failed/i)).toBeVisible({
timeout: 30_000,
});
await expect(
page.getByTestId("file-explorer-tree-scroll").getByText("disconnected.txt", { exact: true }),
).toHaveCount(0);
});
test("keeps an entry visible when deletion fails", async ({ page }) => {
await failNextDeleteRequest(page);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
const readme = page
.getByTestId("file-explorer-tree-scroll")
.getByText("README.md", { exact: true });
await readme.click({ button: "right" });
const confirmation = new Promise<void>((resolve) => {
page.once("dialog", async (dialog) => {
await dialog.accept();
resolve();
});
});
await page.getByText("Delete", { exact: true }).click();
await confirmation;
await expect(page.getByText("Injected delete failure", { exact: true })).toBeVisible();
await expect(readme).toBeVisible();
});
test("hides unsupported file operations and revert actions", async ({ page }) => {
await hideContextActionCapabilities(page);
await writeFile(path.join(workspace.repoPath, "README.md"), "# Changed while unsupported\n");
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
await expect(page.getByTestId("files-new-file")).toHaveCount(0);
await expect(page.getByTestId("files-new-folder")).toHaveCount(0);
await page
.getByTestId("file-explorer-tree-scroll")
.getByText("README.md", { exact: true })
.click({ button: "right" });
const filesMenu = page.getByTestId(/file-explorer-row-\d+-context-menu$/);
await expect(filesMenu.getByText("Rename", { exact: true })).toHaveCount(0);
await expect(filesMenu.getByText("Duplicate", { exact: true })).toHaveCount(0);
await expect(filesMenu.getByText("Delete", { exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
await page.getByTestId("explorer-tab-changes").click();
await expect(page.getByTestId("diff-file-0")).toBeVisible({ timeout: 30_000 });
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0);
await expect(page.getByTestId("diff-file-0-revert")).toHaveCount(0);
});
@@ -0,0 +1,68 @@
import { test } from "../support/fixtures";
import {
expectCompletedDiagram,
expectDiagramRemainsRenderedWhileStreaming,
expectDiagramWithLabels,
reloadConversation,
requestDiagram,
waitForDiagramTurnToComplete,
} from "../support/helpers/diagram";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
const STREAMED_MERMAID = [
"```mermaid",
"flowchart LR",
" Start --> Middle",
' Middle --> Done["<i>Done</i>"]',
" Middle --> Review",
" Review --> Verify",
" Verify --> Ship",
" Ship --> Package",
" Package --> Sign",
" Sign --> Upload",
" Upload --> Publish",
" Publish --> Deploy",
" Deploy --> Observe",
" Observe --> Validate",
" Validate --> Announce",
" Announce --> Document",
" Document --> Archive",
" Archive --> Release",
"```",
].join("\n");
test("keeps a Mermaid diagram rendered while its message streams, completes, and reloads", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "mermaid-streaming-",
title: "Streaming Mermaid",
featureValues: {
mockStreamingAssistantResponse: STREAMED_MERMAID,
mockStreamingAssistantIntervalMs: 150,
},
});
try {
await test.step("Open the conversation and request a diagram", async () => {
await openAgentRoute(page, agent);
await requestDiagram(agent);
});
await test.step("The diagram renders and stays rendered while tokens arrive", async () => {
await expectDiagramWithLabels(page, ["Start", "Middle"]);
await expectDiagramRemainsRenderedWhileStreaming(page);
});
await test.step("The completed diagram shows the final streamed content", async () => {
await waitForDiagramTurnToComplete(agent);
await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
});
await test.step("The completed diagram remains rendered after reload", async () => {
await reloadConversation(page);
await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
});
} finally {
await agent.cleanup();
}
});
@@ -0,0 +1,110 @@
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import {
expectSettingsHeader,
openHostSection,
openSettingsHost,
} from "../support/helpers/settings";
async function openMetadataGenerationSettings(page: Page) {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await openHostSection(page, serverId, "metadata");
await expectSettingsHeader(page, "Metadata");
}
async function openManualMetadataModelPicker(page: Page) {
await page.getByRole("button", { name: "Manual", exact: true }).click();
await page.getByRole("button", { name: /Select model/ }).click();
}
test("chooses a metadata model and can return to automatic selection", async ({
page,
}, testInfo) => {
await openMetadataGenerationSettings(page);
await expect(page.getByText("Workspace titles", { exact: false })).toBeVisible();
await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute(
"aria-selected",
"true",
);
await page.screenshot({
path: testInfo.outputPath("metadata-automatic.png"),
fullPage: true,
});
await openManualMetadataModelPicker(page);
await page.getByText("Mock Load Test", { exact: true }).click();
await expect(page.getByText("Ten second stream", { exact: true })).toBeVisible();
await page.screenshot({
path: testInfo.outputPath("metadata-model-picker.png"),
fullPage: true,
});
await page.getByText("Ten second stream", { exact: true }).click();
await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
await page.reload();
await expect(page.getByRole("button", { name: "Manual", exact: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(page.getByRole("button", { name: /Ten second stream/ })).toBeVisible();
await page.screenshot({
path: testInfo.outputPath("metadata-manual-persisted.png"),
fullPage: true,
});
await page.getByRole("button", { name: "Automatic", exact: true }).click();
await page.reload();
await expect(page.getByRole("button", { name: "Automatic", exact: true })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(page.getByRole("button", { name: /Ten second stream/ })).toHaveCount(0);
});
test("replaces only the first configured metadata model", async ({ page }) => {
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
try {
await client.patchDaemonConfig({
metadataGeneration: {
providers: [
{ provider: "mock", model: "five-minute-stream" },
{ provider: "mock", model: "thirty-minute-stream" },
],
},
});
await openMetadataGenerationSettings(page);
await expect(page.getByRole("button", { name: "Manual", exact: true })).toHaveAttribute(
"aria-selected",
"true",
);
await openManualMetadataModelPicker(page);
await page.getByText("Ten second stream", { exact: true }).click();
await expect
.poll(async () => (await client.getDaemonConfig()).config.metadataGeneration.providers)
.toEqual([
{ provider: "mock", model: "ten-second-stream" },
{ provider: "mock", model: "thirty-minute-stream" },
]);
} finally {
try {
await client.patchDaemonConfig({
metadataGeneration: {
providers: previousConfig.config.metadataGeneration.providers,
},
});
} finally {
await client.close().catch(() => undefined);
}
}
});
@@ -0,0 +1,160 @@
import { test } from "../support/fixtures";
import {
closeModelPicker,
expectModelSearchEmptyState,
expectModelPickerHeight,
expectModelPickerWidth,
expectModelSearchResult,
expectPinnedProfilesHidden,
openModelPicker,
readModelPickerHeight,
readModelPickerWidth,
searchAllModels,
seedAgentProfiles,
seedModelProvider,
expectSearchResultsVirtualized,
} from "../support/helpers/agent-profiles";
import { expectComposerVisible } from "../support/helpers/composer";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
const MOCK_PROVIDER_LABEL = "Mock Load Test";
// Its fast model deliberately carries the same label as the mock provider's, so
// the only thing telling the two result rows apart is the provider name.
const STUDIO = {
id: "mock-studio",
label: "Mock Studio",
models: [
{ id: "studio-fast", label: "Ten second stream", description: "Studio quick pass" },
{ id: "studio-deep", label: "Studio deep think", description: "Studio long pass" },
],
};
// The cross-provider search lives on the picker's root view, and a profile is
// what keeps the picker opening there.
const ROOT_VIEW_PROFILE = {
id: "agent_profile_e2e_search_root",
name: "Search anchor",
provider: "mock",
};
const LARGE_CATALOG_SIZE = 200;
const LARGE_CATALOG = {
id: "mock-bulk",
label: "Mock Bulk",
models: Array.from({ length: LARGE_CATALOG_SIZE }, (_, index) => ({
id: `bulk-${index}`,
label: `Bulk model ${index}`,
description: `Bulk search result ${index}`,
})),
};
test.describe("Cross-provider model search", () => {
test("one query over the picker root reaches every provider and names each result's provider", async ({
page,
}) => {
const provider = await seedModelProvider(STUDIO);
const profile = await seedAgentProfiles([ROOT_VIEW_PROFILE]);
const workspace = await seedWorkspace({ repoPrefix: "model-search-providers-" });
try {
await test.step("open a draft composer that can reach every provider", async () => {
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
await expectComposerVisible(page);
await openModelPicker(page);
});
const restingWidth = await readModelPickerWidth(page);
await test.step("one query returns the same model label from two providers", async () => {
await searchAllModels(page, "ten second stream");
await expectModelSearchResult(page, {
provider: "mock",
modelId: "ten-second-stream",
modelLabel: "Ten second stream",
providerLabel: MOCK_PROVIDER_LABEL,
});
await expectModelSearchResult(page, {
provider: STUDIO.id,
modelId: "studio-fast",
modelLabel: "Ten second stream",
providerLabel: STUDIO.label,
});
await expectModelPickerWidth(page, restingWidth);
});
await test.step("searching by provider name reaches that provider's own models", async () => {
await searchAllModels(page, "studio deep");
await expectModelSearchResult(page, {
provider: STUDIO.id,
modelId: "studio-deep",
modelLabel: "Studio deep think",
providerLabel: STUDIO.label,
});
});
await test.step("results replace the pinned profiles", async () => {
await expectPinnedProfilesHidden(page);
});
// Search falls back to subsequence matching, so "no matches" needs letters
// that cannot be picked out of a model label or description in order.
await test.step("a query with no matches repeats the query back", async () => {
await searchAllModels(page, "zzz");
await expectModelSearchEmptyState(page, "zzz");
await closeModelPicker(page);
});
} finally {
await workspace.cleanup();
await profile.restore();
await provider.restore();
}
});
test("desktop search keeps its frame stable and virtualizes a host-wide catalog", async ({
page,
}) => {
const provider = await seedModelProvider(LARGE_CATALOG);
const profile = await seedAgentProfiles([ROOT_VIEW_PROFILE]);
const workspace = await seedWorkspace({ repoPrefix: "model-search-large-catalog-" });
try {
await test.step("open the host-wide model picker", async () => {
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
await expectComposerVisible(page);
await openModelPicker(page);
});
const restingHeight = await readModelPickerHeight(page);
const restingWidth = await readModelPickerWidth(page);
await test.step("a broad query renders only the visible result window", async () => {
await searchAllModels(page, "bulk model");
await expectSearchResultsVirtualized(page, {
provider: LARGE_CATALOG.id,
total: LARGE_CATALOG_SIZE,
});
await expectModelPickerHeight(page, restingHeight);
await expectModelPickerWidth(page, restingWidth);
});
await test.step("narrowing to one result does not resize the picker", async () => {
await searchAllModels(page, "bulk model 199");
await expectModelSearchResult(page, {
provider: LARGE_CATALOG.id,
modelId: "bulk-199",
modelLabel: "Bulk model 199",
providerLabel: LARGE_CATALOG.label,
});
await expectModelPickerHeight(page, restingHeight);
await expectModelPickerWidth(page, restingWidth);
});
} finally {
await workspace.cleanup();
await profile.restore();
await provider.restore();
}
});
});
@@ -0,0 +1,116 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "../support/fixtures";
import {
cleanupRewindFlow,
launchAgent,
sendMessage,
type AgentHandle,
type RewindFlowProvider,
} from "../support/helpers/rewind-flow";
import {
expectCompletedTaskActivity,
expectTaskListEntries,
openCompletedTaskList,
} from "../support/helpers/task-list";
import { submitMessage } from "../support/helpers/composer";
interface TaskScenario {
provider: RewindFlowProvider;
entries: [string, string];
prompt: string;
providerConfig?: Parameters<typeof launchAgent>[0]["providerConfig"];
}
const scenarios: TaskScenario[] = [
{
provider: "claude",
entries: ["Claude inventory alpha", "Claude inventory beta"],
providerConfig: { model: "opus" },
prompt:
"Use TaskCreate to create exactly two tasks named Claude inventory alpha and Claude inventory beta. Use TaskUpdate to mark each in_progress and then completed. Do not use subagents, shell commands, or edit files. Finish with TASKS_COMPLETE.",
},
{
provider: "codex",
entries: ["Codex inventory alpha", "Codex inventory beta"],
prompt:
"Use update_plan to track exactly two steps named Codex inventory alpha and Codex inventory beta. Move each through in_progress and completed. Do not use subagents, shell commands, or edit files. Finish with TASKS_COMPLETE.",
},
{
provider: "opencode",
entries: ["OpenCode inventory alpha", "OpenCode inventory beta"],
prompt:
"Use TodoWrite to track exactly two tasks named OpenCode inventory alpha and OpenCode inventory beta. Move each through in_progress and completed. Do not use subagents, shell commands, or edit files. Finish with TASKS_COMPLETE.",
},
];
test.describe("real provider composer task lists", () => {
test.setTimeout(600_000);
for (const scenario of scenarios) {
test(`${scenario.provider} shows native task progress above the composer`, async ({
page,
}, testInfo) => {
const cwd = realpathSync(
mkdtempSync(path.join(tmpdir(), `paseo-task-list-${scenario.provider}-`)),
);
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: scenario.provider,
cwd,
mode: "full-access",
providerConfig: scenario.providerConfig,
});
await sendMessage(handle, scenario.prompt);
await openCompletedTaskList(page, scenario.entries.length);
await expectTaskListEntries(page, scenario.entries);
await expectCompletedTaskActivity(page, scenario.entries);
await expect(page.getByText("TaskUpdate", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
const screenshot = testInfo.outputPath(`${scenario.provider}-composer-tasks.png`);
await page.screenshot({ path: screenshot });
await testInfo.attach(`${scenario.provider} composer tasks`, {
path: screenshot,
contentType: "image/png",
});
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
}
test("Codex genuine Plan mode keeps the existing plan card", async ({ page }, testInfo) => {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-genuine-codex-plan-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "codex",
cwd,
mode: "full-access",
providerConfig: { featureValues: { plan_mode: true } },
});
await submitMessage(
page,
"Produce a concise implementation plan with exactly these steps: Inspect genuine plan boundary; Implement genuine plan boundary; Verify genuine plan boundary. Do not implement it.",
);
const planCard = page.getByTestId("permission-plan-card");
await expect(planCard).toBeVisible({ timeout: 120_000 });
await expect(planCard).toContainText("Inspect genuine plan boundary");
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
const screenshot = testInfo.outputPath("codex-genuine-plan.png");
await page.screenshot({ path: screenshot });
await testInfo.attach("Codex genuine plan", { path: screenshot, contentType: "image/png" });
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});
@@ -0,0 +1,65 @@
import { expect, test as base } from "../support/fixtures";
import { expectAgentIdle } from "../support/helpers/agent-stream";
import { expectComposerVisible, submitMessage } from "../support/helpers/composer";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
import {
beginReplicaCacheMeasurement,
observeReplicaCacheWrites,
readReplicaCacheMeasurement,
} from "../support/helpers/replica-cache-perf";
const RUN_REPLICA_CACHE_PERF = process.env.PASEO_REPLICA_CACHE_PERF_E2E === "1";
const MAX_STREAM_WRITES = 12;
const MAX_SERIALIZED_CHARS = 190_000;
const test = base.extend<{ replicaCacheAgent: MockAgentWorkspace }>({
replicaCacheAgent: async ({ page: _page }, provide) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "replica-cache-perf-",
title: "Replica cache performance",
model: "ten-second-stream",
});
try {
await provide(agent);
} finally {
await agent.cleanup();
}
},
});
const replicaCachePerfDescribe = RUN_REPLICA_CACHE_PERF ? test.describe : test.describe.skip;
replicaCachePerfDescribe("Replica cache performance", () => {
test("streaming writes only persisted replica changes", async ({
page,
replicaCacheAgent: agent,
}, testInfo) => {
test.setTimeout(60_000);
await observeReplicaCacheWrites(page);
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await beginReplicaCacheMeasurement(page);
await submitMessage(page, "Measure replica persistence during a sustained stream.");
await agent.client.waitForFinish(agent.agentId, 20_000);
await expectAgentIdle(page);
await page.waitForTimeout(1_000);
const report = await readReplicaCacheMeasurement(page);
await testInfo.attach("replica-cache-performance", {
body: JSON.stringify(report, null, 2),
contentType: "application/json",
});
console.log(`[perf] Replica cache: ${JSON.stringify(report)}`);
expect(
report.writes,
"streaming should not continuously persist transient state",
).toBeLessThanOrEqual(MAX_STREAM_WRITES);
expect(report.redundantWrites).toBe(0);
expect(report.serializedChars).toBeLessThanOrEqual(MAX_SERIALIZED_CHARS);
});
});
@@ -0,0 +1,564 @@
import { expect, type Locator, type Page } from "@playwright/test";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
import { gotoAppShell, openSettings } from "./app";
import { connectDaemonClient } from "./daemon-client-loader";
import { getServerId } from "./server-id";
import { expectAppRoute } from "./route-assertions";
import { openSettingsHost } from "./settings";
// ─── Daemon-side seeding ───────────────────────────────────────────────────
interface AgentProfilesDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
getDaemonConfig(): Promise<{ config: { agentProfiles?: AgentProfile[] } }>;
patchDaemonConfig(config: {
agentProfiles?: AgentProfile[];
providers?: Record<string, Record<string, unknown>>;
removeProviders?: string[];
}): Promise<unknown>;
}
export interface HostSeed {
/** Puts the host's config back the way the test found it and drops the client. */
restore(): Promise<void>;
}
async function connectAgentProfilesClient(): Promise<AgentProfilesDaemonClient> {
return connectDaemonClient<AgentProfilesDaemonClient>({ clientIdPrefix: "agent-profiles-e2e" });
}
/**
* Writes profiles into host config directly. Journeys that are *about* the
* settings UI create theirs through it; every other journey starts from a host
* that already has them.
*/
export async function seedAgentProfiles(profiles: AgentProfile[]): Promise<HostSeed> {
const client = await connectAgentProfilesClient();
const previous = (await client.getDaemonConfig()).config.agentProfiles ?? [];
await client.patchDaemonConfig({ agentProfiles: profiles });
return {
async restore() {
await client.patchDaemonConfig({ agentProfiles: previous }).catch(() => undefined);
await client.close().catch(() => undefined);
},
};
}
export async function stageLegacyFavoritesForHostMigration(
page: Page,
favorites: Array<{ provider: string; modelId: string }>,
): Promise<void> {
const serverId = getServerId();
await gotoAppShell(page);
await page.evaluate(
({ favorites: storedFavorites, serverId: hostId }) => {
const preferencesKey = "@paseo:create-agent-preferences";
const raw = localStorage.getItem(preferencesKey);
const preferences = raw ? JSON.parse(raw) : {};
localStorage.setItem(
preferencesKey,
JSON.stringify({ ...preferences, favoriteModels: storedFavorites }),
);
localStorage.removeItem(`@paseo:legacy-favorites-to-agent-profiles:v1:${hostId}`);
// Preserve this upgrade-shaped storage across the fixture's next-page
// default seed; a reload is the startup boundary under test.
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce");
if (nonce) {
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
}
},
{ favorites, serverId },
);
await page.reload();
}
export async function expectHostAgentProfiles(expected: AgentProfile[]): Promise<void> {
const client = await connectAgentProfilesClient();
try {
await expect
.poll(async () => (await client.getDaemonConfig()).config.agentProfiles ?? [], {
timeout: 30_000,
})
.toEqual(expected);
} finally {
await client.close().catch(() => undefined);
}
}
export interface SeededProviderModel {
id: string;
label: string;
description: string;
}
/**
* Registers a second provider that reports a fixed catalog. The E2E daemon has
* exactly one provider with models (`mock`), so nothing cross-provider can be
* proven without one more.
*
* `extends: "mock"` is rejected — the config validator only accepts the six
* shipped providers plus `acp`. Extending `claude` with a replacement `models`
* 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`.
*/
export async function seedModelProvider(input: {
id: string;
label: string;
models: SeededProviderModel[];
}): Promise<HostSeed> {
const client = await connectAgentProfilesClient();
await client.patchDaemonConfig({
providers: {
[input.id]: {
extends: "claude",
label: input.label,
description: `${input.label} test provider`,
enabled: true,
command: ["node"],
models: input.models,
},
},
});
return {
async restore() {
await client.patchDaemonConfig({ removeProviders: [input.id] }).catch(() => undefined);
await client.close().catch(() => undefined);
},
};
}
// ─── Settings: navigation ──────────────────────────────────────────────────
/** Reach agent profiles through the same visible Settings path a person uses. */
export async function openAgentProfileSettings(page: Page): Promise<void> {
const serverId = getServerId();
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, serverId);
await page.getByRole("button", { name: "Agents", exact: true }).click();
await expectAppRoute(page, buildSettingsHostSectionRoute(serverId, "agents"));
await expect(page.getByTestId("agent-profiles-card")).toBeVisible({ timeout: 30_000 });
}
export async function expectNoAgentProfiles(page: Page): Promise<void> {
const card = page.getByTestId("agent-profiles-card");
await expect(card.getByTestId("agent-profiles-empty")).toBeVisible();
await expect(card.getByRole("button", { name: "New profile", exact: true })).toBeVisible();
}
// ─── Settings: the profile list ────────────────────────────────────────────
export function agentProfileRow(page: Page, name: string): Locator {
return page.locator('[data-testid^="agent-profile-row-"]').filter({ hasText: name });
}
export async function expectAgentProfile(
page: Page,
expected: { name: string; tags: string[]; notes?: string },
): Promise<void> {
const row = agentProfileRow(page, expected.name);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByText(expected.tags.join(" · "), { exact: true })).toBeVisible();
if (expected.notes !== undefined) {
await expect(row.getByText(expected.notes, { exact: true })).toBeVisible();
}
}
export async function expectAgentProfileTagsGone(
page: Page,
input: { name: string; tags: string[] },
): Promise<void> {
const row = agentProfileRow(page, input.name);
for (const tag of input.tags) {
await expect(row.getByText(tag, { exact: true })).toHaveCount(0);
}
}
/** Order is what the user sees, so it is measured on screen rather than in the DOM. */
export async function expectAgentProfileOrder(page: Page, names: string[]): Promise<void> {
await expect
.poll(
async () => {
const measured = await Promise.all(
names.map(async (name) => ({ name, top: await boxTop(agentProfileRow(page, name)) })),
);
return measured.sort((a, b) => a.top - b.top).map((entry) => entry.name);
},
{ timeout: 15_000 },
)
.toEqual(names);
}
export async function moveAgentProfileUp(page: Page, name: string): Promise<void> {
await agentProfileRow(page, name).getByRole("button", { name: "Move up", exact: true }).click();
}
export async function moveAgentProfileDown(page: Page, name: string): Promise<void> {
await agentProfileRow(page, name).getByRole("button", { name: "Move down", exact: true }).click();
}
/**
* The remove confirmation is `window.confirm` on browser web, so the dialog is
* consumed here — and its text is asserted, since naming the profile is the
* whole point of confirming.
*/
export async function removeAgentProfile(page: Page, name: string): Promise<void> {
const dialogMessage = page.waitForEvent("dialog").then(async (dialog) => {
const message = dialog.message();
await dialog.accept();
return message;
});
await agentProfileRow(page, name).getByRole("button", { name: "Remove", exact: true }).click();
expect(await dialogMessage).toContain(`Remove "${name}"?`);
await expect(agentProfileRow(page, name)).toHaveCount(0, { timeout: 30_000 });
}
// ─── Settings: the edit modal ──────────────────────────────────────────────
export interface AgentProfileDraft {
name?: string;
provider?: string;
model?: string;
thinking?: string;
mode?: string;
notes?: string;
}
function editModal(page: Page): Locator {
return page.getByTestId("agent-profile-edit-modal");
}
async function chooseSelectFieldOption(
page: Page,
input: { field: "provider" | "model" | "mode" | "thinking"; option: string },
): Promise<void> {
await editModal(page).getByTestId(`agent-profile-${input.field}-trigger`).click();
const option = page
.getByTestId("combobox-desktop-container")
.getByRole("button", { name: input.option, exact: true });
await expect(option).toBeVisible({ timeout: 30_000 });
await option.click();
await expect(
page.getByRole("button", { name: `${capitalize(input.field)} (${input.option})` }),
).toBeVisible({ timeout: 30_000 });
}
function capitalize(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}
async function fillAgentProfileForm(page: Page, draft: AgentProfileDraft): Promise<void> {
const modal = editModal(page);
if (draft.name !== undefined) {
await modal.getByRole("textbox", { name: "Name", exact: true }).fill(draft.name);
}
// Provider first: it resets every provider-scoped field below it.
if (draft.provider !== undefined) {
await chooseSelectFieldOption(page, { field: "provider", option: draft.provider });
}
// Thinking after model: thinking levels hang off the model, and switching the
// model drops a level the new one does not offer.
if (draft.model !== undefined) {
await chooseSelectFieldOption(page, { field: "model", option: draft.model });
}
if (draft.thinking !== undefined) {
await chooseSelectFieldOption(page, { field: "thinking", option: draft.thinking });
}
if (draft.mode !== undefined) {
await chooseSelectFieldOption(page, { field: "mode", option: draft.mode });
}
if (draft.notes !== undefined) {
await modal.getByRole("textbox", { name: "Notes for agents", exact: true }).fill(draft.notes);
}
}
async function saveAgentProfile(page: Page): Promise<void> {
await editModal(page).getByRole("button", { name: "Save", exact: true }).click();
await expect(editModal(page)).toHaveCount(0, { timeout: 30_000 });
}
export async function createAgentProfile(page: Page, draft: AgentProfileDraft): Promise<void> {
await page.getByRole("button", { name: "Add agent profile", exact: true }).click();
await expect(editModal(page)).toBeVisible({ timeout: 30_000 });
await fillAgentProfileForm(page, draft);
await saveAgentProfile(page);
}
export async function createAgentProfileFromEmptyState(
page: Page,
draft: AgentProfileDraft,
): Promise<void> {
await page
.getByTestId("agent-profiles-card")
.getByRole("button", { name: "New profile", exact: true })
.click();
await expect(editModal(page)).toBeVisible({ timeout: 30_000 });
await fillAgentProfileForm(page, draft);
await saveAgentProfile(page);
}
export async function editAgentProfile(
page: Page,
name: string,
changes: AgentProfileDraft,
): Promise<void> {
await agentProfileRow(page, name)
.getByRole("button", { name: "Edit profile", exact: true })
.click();
await expect(editModal(page)).toBeVisible({ timeout: 30_000 });
await fillAgentProfileForm(page, changes);
await saveAgentProfile(page);
}
/** Opens the modal for a profile purely to read back what it stored. */
export async function expectAgentProfileForm(
page: Page,
name: string,
expected: { provider?: string; model?: string; mode?: string; notes?: string },
): Promise<void> {
await agentProfileRow(page, name)
.getByRole("button", { name: "Edit profile", exact: true })
.click();
const modal = editModal(page);
await expect(modal).toBeVisible({ timeout: 30_000 });
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(name);
for (const [field, value] of Object.entries(expected)) {
if (field === "notes") {
await expect(
modal.getByRole("textbox", { name: "Notes for agents", exact: true }),
).toHaveValue(value);
continue;
}
await expect(
modal.getByRole("button", { name: `${capitalize(field)} (${value})` }),
).toBeVisible({ timeout: 30_000 });
}
await modal.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(editModal(page)).toHaveCount(0, { timeout: 30_000 });
}
// ─── The model picker ──────────────────────────────────────────────────────
/** Desktop web renders the model browser inside the combobox popover. */
function pickerViewport(page: Page): Locator {
return page.getByTestId("combobox-desktop-container");
}
export async function openModelPicker(page: Page): Promise<void> {
await page.getByTestId("combined-model-selector").filter({ visible: true }).first().click();
await expect(pickerViewport(page)).toBeVisible({ timeout: 30_000 });
}
export async function closeModelPicker(page: Page): Promise<void> {
await page.keyboard.press("Escape");
await expect(pickerViewport(page)).toHaveCount(0, { timeout: 30_000 });
}
export function profilePickerRow(page: Page, name: string): Locator {
return pickerViewport(page)
.locator('[data-testid^="model-profile-row-"]')
.filter({ hasText: name });
}
/**
* Profiles are pinned above the provider list, so "pinned" is an ordering claim:
* the profile row's top edge sits above every provider row's.
*/
export async function expectProfilePinnedAboveProviders(
page: Page,
input: { name: string; summary: string },
): Promise<void> {
const viewport = pickerViewport(page);
await expect(viewport.getByText("Profiles", { exact: true })).toBeVisible({ timeout: 30_000 });
const row = profilePickerRow(page, input.name);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByText(input.summary, { exact: true })).toBeVisible();
const profileTop = await boxTop(row);
const providerRows = viewport.locator('[data-testid^="model-provider-"]');
await expect(providerRows.first()).toBeVisible();
const providerCount = await providerRows.count();
for (let index = 0; index < providerCount; index += 1) {
expect(profileTop).toBeLessThan(await boxTop(providerRows.nth(index)));
}
}
export async function expectProfileVisibleForProvider(
page: Page,
input: { name: string; summary: string },
): Promise<void> {
const viewport = pickerViewport(page);
await expect(viewport.getByText("Profiles", { exact: true })).toBeVisible({ timeout: 30_000 });
const row = profilePickerRow(page, input.name);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByText(input.summary, { exact: true })).toBeVisible();
}
export async function expectProfileEditIsPencilOnly(page: Page): Promise<void> {
const viewport = pickerViewport(page);
await expect(
viewport.getByRole("button", { name: "Edit agent profiles", exact: true }),
).toBeVisible();
await expect(viewport.getByText("Edit", { exact: true })).toHaveCount(0);
}
export async function expectAgentProfilesEmptyPrompt(page: Page): Promise<void> {
const viewport = pickerViewport(page);
await expect(viewport.getByText("Profiles", { exact: true })).toHaveCount(0);
await expect(
viewport.getByRole("button", { name: "Edit agent profiles", exact: true }),
).toHaveCount(0);
await expect(viewport.getByRole("button", { name: "Create profile", exact: true })).toBeVisible({
timeout: 30_000,
});
}
export async function expectProfileEditTooltip(page: Page): Promise<void> {
const edit = pickerViewport(page).getByRole("button", {
name: "Edit agent profiles",
exact: true,
});
await edit.hover();
await expect(page.getByText("Edit agent profiles", { exact: true })).toBeVisible();
}
export async function openAgentProfilesFromEmptyPrompt(page: Page): Promise<void> {
await pickerViewport(page).getByRole("button", { name: "Create profile", exact: true }).click();
await expect(page.getByTestId("agent-profiles-card")).toBeVisible({ timeout: 30_000 });
}
async function boxTop(locator: Locator): Promise<number> {
const box = await locator.boundingBox();
if (!box) {
throw new Error("Expected a laid-out element to measure");
}
return box.y;
}
export async function applyProfileFromPicker(page: Page, name: string): Promise<void> {
await profilePickerRow(page, name).click();
await expect(pickerViewport(page)).toHaveCount(0, { timeout: 30_000 });
}
/**
* Applying a profile materializes it and forgets it: nothing in the root view
* claims selection. Model rows do carry `aria-selected`, so a count of zero here
* is the absence of a checkmark, not the absence of the attribute everywhere.
*/
export async function expectNothingSelectedInPickerRoot(page: Page): Promise<void> {
await expect(pickerViewport(page).locator("[aria-selected]")).toHaveCount(0);
}
export async function expectAgentProfilesEditShortcut(page: Page): Promise<void> {
await expect(
pickerViewport(page).getByRole("button", { name: "Edit agent profiles", exact: true }),
).toBeVisible({ timeout: 30_000 });
}
export async function drillIntoProvider(page: Page, providerId: string): Promise<void> {
await pickerViewport(page).getByTestId(`model-provider-${providerId}`).click();
}
export async function expectModelRowSelected(
page: Page,
input: { provider: string; modelId: string },
): Promise<void> {
await expect(
pickerViewport(page).getByTestId(`model-row-${input.provider}-${input.modelId}`),
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
}
// ─── Cross-provider model search ───────────────────────────────────────────
export async function searchAllModels(page: Page, query: string): Promise<void> {
const input = page.getByTestId("model-search-all-input");
await expect(input).toBeVisible({ timeout: 30_000 });
await input.fill(query);
}
export async function expectModelSearchResult(
page: Page,
expected: { provider: string; modelId: string; providerLabel: string; modelLabel: string },
): Promise<void> {
const row = pickerViewport(page).getByTestId(
`model-row-${expected.provider}-${expected.modelId}`,
);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByText(expected.modelLabel, { exact: true })).toBeVisible();
await expect(
row.getByText(new RegExp(`^${escapeForRegex(expected.providerLabel)}\\b`)),
).toBeVisible();
}
function escapeForRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** Search results replace the whole root view, pinned profiles included. */
export async function expectPinnedProfilesHidden(page: Page): Promise<void> {
await expect(pickerViewport(page).locator('[data-testid^="model-profile-row-"]')).toHaveCount(0);
}
export async function expectModelSearchEmptyState(page: Page, query: string): Promise<void> {
const empty = page.getByTestId("model-search-empty");
await expect(empty).toBeVisible({ timeout: 30_000 });
await expect(empty.getByText(`No models match "${query}"`, { exact: true })).toBeVisible();
}
export async function readModelPickerHeight(page: Page): Promise<number> {
const box = await pickerViewport(page).boundingBox();
if (!box) {
throw new Error("Expected the model picker to be laid out");
}
return box.height;
}
export async function readModelPickerWidth(page: Page): Promise<number> {
const box = await pickerViewport(page).boundingBox();
if (!box) {
throw new Error("Expected the model picker to be laid out");
}
return box.width;
}
export async function expectModelPickerHeight(page: Page, expectedHeight: number): Promise<void> {
await expect.poll(() => readModelPickerHeight(page)).toBe(expectedHeight);
}
export async function expectModelPickerWidth(page: Page, expectedWidth: number): Promise<void> {
await expect.poll(() => readModelPickerWidth(page)).toBe(expectedWidth);
}
export async function expectSearchResultsVirtualized(
page: Page,
input: { provider: string; total: number },
): Promise<void> {
const mountedRows = pickerViewport(page).locator(`[data-testid^="model-row-${input.provider}-"]`);
await expect.poll(() => mountedRows.count()).toBeGreaterThan(0);
expect(await mountedRows.count()).toBeLessThan(input.total);
}
// ─── Composer agent controls ───────────────────────────────────────────────
export async function expectComposerModel(page: Page, modelLabel: string): Promise<void> {
await expect(
page.getByRole("button", { name: `Select model (${modelLabel})`, exact: true }),
).toBeVisible({ timeout: 30_000 });
}
export async function expectComposerMode(page: Page, modeLabel: string): Promise<void> {
await expect(
page.getByRole("button", { name: `Select agent mode (${modeLabel})`, exact: true }).first(),
).toBeVisible({ timeout: 30_000 });
}
/** The composer never shows the profile's name — a profile is not a selection. */
export async function expectComposerDoesNotName(page: Page, profileName: string): Promise<void> {
await expect(
page.locator('[data-testid="message-input-root"]:visible').getByText(profileName),
).toHaveCount(0);
}
@@ -0,0 +1,48 @@
import { expect, type Page } from "@playwright/test";
import type { MockAgentWorkspace } from "./mock-agent";
const DIAGRAM_NAME = "Diagram";
function renderedDiagram(page: Page) {
const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last();
const svg = diagram.locator("iframe").contentFrame().locator("#diagram svg");
return { diagram, svg };
}
export async function requestDiagram(agent: MockAgentWorkspace): Promise<void> {
await agent.client.sendAgentMessage(agent.agentId, "Render the requested Mermaid diagram.");
}
export async function expectDiagramWithLabels(
page: Page,
labels: readonly string[],
): Promise<void> {
const { diagram, svg } = renderedDiagram(page);
await expect(diagram).toBeVisible({ timeout: 30_000 });
await expect(svg).toBeVisible({ timeout: 30_000 });
for (const label of labels) {
await expect(svg).toContainText(label);
}
}
export async function expectDiagramRemainsRenderedWhileStreaming(page: Page): Promise<void> {
const { diagram, svg } = renderedDiagram(page);
const samples = 80;
for (let sample = 0; sample < samples; sample += 1) {
await expect(diagram).toBeVisible({ timeout: 100 });
await expect(svg).toBeVisible({ timeout: 100 });
await page.waitForTimeout(25);
}
}
export async function waitForDiagramTurnToComplete(agent: MockAgentWorkspace): Promise<void> {
await agent.client.waitForFinish(agent.agentId, 30_000);
}
export async function expectCompletedDiagram(page: Page, labels: readonly string[]): Promise<void> {
await expectDiagramWithLabels(page, labels);
}
export async function reloadConversation(page: Page): Promise<void> {
await page.reload();
}
@@ -0,0 +1,80 @@
import type { Page } from "@playwright/test";
const REPLICA_CACHE_STORAGE_KEY = "@paseo:replica-cache";
export interface ReplicaCachePerfReport {
elapsedMs: number;
redundantWrites: number;
serializedChars: number;
storageDurationMs: number;
writes: number;
}
interface ReplicaCachePerfState {
lastValue: string | null;
resetAt: number;
redundantWrites: number;
serializedChars: number;
storageDurationMs: number;
writes: number;
}
declare global {
interface Window {
__replicaCachePerf?: ReplicaCachePerfState;
}
}
export async function observeReplicaCacheWrites(page: Page): Promise<void> {
await page.addInitScript((storageKey) => {
const originalSetItem = Storage.prototype.setItem;
window.__replicaCachePerf = {
lastValue: localStorage.getItem(storageKey),
resetAt: performance.now(),
redundantWrites: 0,
serializedChars: 0,
storageDurationMs: 0,
writes: 0,
};
Storage.prototype.setItem = function measuredSetItem(key: string, value: string): void {
const startedAt = performance.now();
originalSetItem.call(this, key, value);
if (key !== storageKey) return;
const state = window.__replicaCachePerf;
if (!state) return;
if (state.lastValue === value) state.redundantWrites += 1;
state.lastValue = value;
state.writes += 1;
state.serializedChars += value.length;
state.storageDurationMs += performance.now() - startedAt;
};
}, REPLICA_CACHE_STORAGE_KEY);
}
export async function beginReplicaCacheMeasurement(page: Page): Promise<void> {
await page.evaluate(() => {
const lastValue = window.__replicaCachePerf?.lastValue ?? null;
window.__replicaCachePerf = {
lastValue,
resetAt: performance.now(),
redundantWrites: 0,
serializedChars: 0,
storageDurationMs: 0,
writes: 0,
};
});
}
export async function readReplicaCacheMeasurement(page: Page): Promise<ReplicaCachePerfReport> {
return page.evaluate(() => {
const state = window.__replicaCachePerf;
if (!state) throw new Error("Replica cache performance observer is not installed");
return {
elapsedMs: performance.now() - state.resetAt,
redundantWrites: state.redundantWrites,
serializedChars: state.serializedChars,
storageDurationMs: state.storageDurationMs,
writes: state.writes,
};
});
}
@@ -36,9 +36,11 @@ type HostSection =
| "connections"
| "pair-device"
| "agents"
| "metadata"
| "workspaces"
| "providers"
| "usage"
| "terminals"
| "host";
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
@@ -0,0 +1,24 @@
import { expect, type Page } from "@playwright/test";
export async function openCompletedTaskList(page: Page, count: number): Promise<void> {
const taskButton = page.getByRole("button", { name: `${count}/${count} tasks` });
await expect(taskButton).toBeVisible({ timeout: 60_000 });
await taskButton.click();
}
export async function expectTaskListEntries(page: Page, entries: readonly string[]): Promise<void> {
for (const entry of entries) {
await expect(page.getByLabel(entry, { exact: true })).toBeVisible();
}
}
export async function expectCompletedTaskActivity(
page: Page,
entries: readonly string[],
): Promise<void> {
for (const entry of entries) {
await expect(
page.getByRole("button", { name: `Completed ${entry}`, exact: true }),
).toBeVisible();
}
}
+5 -1
View File
@@ -29,7 +29,8 @@
"build:web": "npm --prefix ../.. run build:app-deps && expo export --platform web",
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs"
},
"dependencies": {
"@codemirror/commands": "6.10.4",
@@ -92,6 +93,7 @@
"i18next": "^26.3.0",
"lucide-react-native": "^0.546.0",
"markdown-it": "^10.0.0",
"mermaid": "^11.16.0",
"mnemonic-id": "^3.2.7",
"qrcode": "^1.5.4",
"react": "19.1.0",
@@ -117,6 +119,7 @@
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
"use-sync-external-store": "^1.6.0",
"yaml": "^2.8.4",
"zod": "^4.4.3",
"zustand": "^5.0.9"
},
@@ -134,6 +137,7 @@
"@xterm/headless": "^6.1.0-beta.213",
"dotenv": "^17.2.3",
"eas-cli": "^16.24.1",
"esbuild": "0.28.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"expo-gradle-jvmargs": "^1.1.2",
@@ -0,0 +1,24 @@
const { withGradleProperties } = require("expo/config-plugins");
const ASYNC_STORAGE_SIZE_PROPERTY = "AsyncStorage_db_size_in_MB";
function withAndroidAsyncStorageSize(config, sizeInMegabytes) {
return withGradleProperties(config, (modConfig) => {
const existingProperty = modConfig.modResults.find(
(property) => property.type === "property" && property.key === ASYNC_STORAGE_SIZE_PROPERTY,
);
if (existingProperty?.type === "property") {
existingProperty.value = String(sizeInMegabytes);
return modConfig;
}
modConfig.modResults.push({
type: "property",
key: ASYNC_STORAGE_SIZE_PROPERTY,
value: String(sizeInMegabytes),
});
return modConfig;
});
}
module.exports = withAndroidAsyncStorageSize;
+26
View File
@@ -0,0 +1,26 @@
/**
* Agent profiles: named bundles of composer settings (provider, model, mode,
* thinking option, feature values, notes) stored host-wide in daemon config.
*
* Four capabilities leave this module — managing the list in settings, reading
* it, pinning it to the model picker, and drawing a profile's glyph. Everything
* else (the form model, the catalog and feature probes, the materialization
* rules, the icon registry, the row and modal chrome) is internal; import from
* `@/agent-profiles`, never a path inside it.
*
* `useAgentProfilePicker` deliberately hands the picker a flat row view model
* and one `applyProfile(id)` callback rather than the profiles themselves: what
* a profile contains, and how it reaches a live agent versus a draft, stays in
* here.
*/
export type { AgentProfile } from "@getpaseo/protocol/messages";
export { useAgentProfiles } from "./internal/use-agent-profiles";
export {
useAgentProfilePicker,
type AgentProfileApplyTarget,
type AgentProfilePicker,
type AgentProfilePickerRow,
type DraftAgentProfileControls,
} from "./internal/use-agent-profile-picker";
export { AgentProfileGlyph } from "./internal/agent-profile-glyph";
export { AgentProfilesSection } from "./settings/agent-profiles-section";
@@ -0,0 +1,128 @@
import { withUnistyles } from "react-native-unistyles";
import {
BookOpen,
Bot,
Boxes,
Brain,
Bug,
Cloud,
Code,
Compass,
Cpu,
Database,
Eye,
Feather,
FileText,
FlaskConical,
GitBranch,
Globe,
Hammer,
Layers,
Microscope,
Package,
Palette,
Pencil,
Rocket,
Search,
Server,
Shield,
Sparkles,
Terminal,
TestTube,
Wrench,
} from "lucide-react-native";
import { identityForeground } from "@/styles/identity-colors";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
AGENT_PROFILE_COLORS,
resolveAgentProfileColor,
resolveAgentProfileIconKey,
type AgentProfileColor,
type AgentProfileIconKey,
} from "./profile-appearance";
/** Drawn when a profile names no icon, and as the "default" cell in the picker grid. */
const ThemedDefaultIcon = withUnistyles(Bot);
/**
* `withUnistyles` has to wrap each icon once at module scope, so the registry
* stores the themed component rather than the raw lucide one.
*/
const THEMED_ICONS: Record<AgentProfileIconKey, typeof ThemedDefaultIcon> = {
code: withUnistyles(Code),
terminal: withUnistyles(Terminal),
bug: withUnistyles(Bug),
wrench: withUnistyles(Wrench),
hammer: withUnistyles(Hammer),
flask: withUnistyles(FlaskConical),
testTube: withUnistyles(TestTube),
microscope: withUnistyles(Microscope),
search: withUnistyles(Search),
eye: withUnistyles(Eye),
palette: withUnistyles(Palette),
feather: withUnistyles(Feather),
pencil: withUnistyles(Pencil),
fileText: withUnistyles(FileText),
book: withUnistyles(BookOpen),
rocket: withUnistyles(Rocket),
package: withUnistyles(Package),
boxes: withUnistyles(Boxes),
server: withUnistyles(Server),
database: withUnistyles(Database),
cpu: withUnistyles(Cpu),
cloud: withUnistyles(Cloud),
globe: withUnistyles(Globe),
gitBranch: withUnistyles(GitBranch),
layers: withUnistyles(Layers),
compass: withUnistyles(Compass),
brain: withUnistyles(Brain),
sparkles: withUnistyles(Sparkles),
shield: withUnistyles(Shield),
};
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/**
* One `uniProps` mapping per colour, built once. Building these inline would
* hand `withUnistyles` a new function every render and defeat its tracking.
*/
const COLOR_MAPPINGS: Record<AgentProfileColor, (theme: Theme) => { color: string }> = (() => {
const byColor = {} as Record<AgentProfileColor, (theme: Theme) => { color: string }>;
for (const color of AGENT_PROFILE_COLORS) {
byColor[color] =
color === "none"
? mutedMapping
: (theme: Theme) => ({ color: identityForeground(color, theme.colorScheme) });
}
return byColor;
})();
export function agentProfileColorMapping(
color: AgentProfileColor,
): (theme: Theme) => { color: string } {
return COLOR_MAPPINGS[color];
}
/**
* How a profile is drawn everywhere it appears — settings list, model picker,
* and the picker grid's own cells. One component so the three can't drift.
*/
export function AgentProfileGlyph({
icon,
color,
size = ICON_SIZE.md,
}: {
icon?: string | undefined;
color?: string | undefined;
size?: number;
}) {
const iconKey = resolveAgentProfileIconKey(icon);
const mapping = COLOR_MAPPINGS[resolveAgentProfileColor(color)];
const Icon = iconKey ? THEMED_ICONS[iconKey] : ThemedDefaultIcon;
return <Icon size={size} uniProps={mapping} />;
}
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { supportsAgentProfiles } from "./capabilities";
describe("agent profile capabilities", () => {
it("requires storage and live config application support", () => {
expect(supportsAgentProfiles(undefined)).toBe(false);
expect(supportsAgentProfiles({ agentProfiles: true })).toBe(false);
expect(supportsAgentProfiles({ agentConfigApply: true })).toBe(false);
expect(supportsAgentProfiles({ agentProfiles: true, agentConfigApply: true })).toBe(true);
});
});
@@ -0,0 +1,8 @@
interface AgentProfileCapabilities {
agentProfiles?: boolean;
agentConfigApply?: boolean;
}
export function supportsAgentProfiles(features: AgentProfileCapabilities | undefined): boolean {
return features?.agentProfiles === true && features.agentConfigApply === true;
}
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { materializeAgentProfile, toAgentConfigApply } from "./materialize-profile";
function profile(overrides: Partial<AgentProfile> = {}): AgentProfile {
return {
id: "ui-work",
name: "UI work",
provider: "claude",
...overrides,
};
}
describe("materializeAgentProfile", () => {
it("resolves every field a full profile names", () => {
expect(
materializeAgentProfile(
profile({
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { web_search: true },
}),
),
).toEqual({
provider: "claude",
modelId: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { web_search: true },
});
});
it("treats omitted and blank fields the same", () => {
expect(materializeAgentProfile(profile({ model: " ", modeId: "" }))).toEqual({
provider: "claude",
modelId: "",
modeId: "",
thinkingOptionId: "",
featureValues: {},
});
});
it("trims stored ids", () => {
expect(materializeAgentProfile(profile({ model: " claude-opus-5 " })).modelId).toBe(
"claude-opus-5",
);
});
});
describe("toAgentConfigApply", () => {
it("sends every value the profile names", () => {
expect(
toAgentConfigApply(
materializeAgentProfile(
profile({
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { web_search: true },
}),
),
),
).toEqual({
modelId: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { web_search: true },
});
});
it("omits what the profile leaves alone rather than clearing it", () => {
expect(toAgentConfigApply(materializeAgentProfile(profile({ modeId: "plan" })))).toEqual({
modeId: "plan",
});
});
it("omits an empty feature map so the agent keeps its own feature values", () => {
expect(
toAgentConfigApply(materializeAgentProfile(profile({ featureValues: {} }))),
).not.toHaveProperty("featureValues");
});
it("never carries a provider, which a running agent cannot change", () => {
expect(
toAgentConfigApply(materializeAgentProfile(profile({ model: "claude-opus-5" }))),
).not.toHaveProperty("provider");
});
});
@@ -0,0 +1,46 @@
import type { AgentConfigApply, AgentProfile } from "@getpaseo/protocol/messages";
/**
* A profile with its blank fields resolved away. Storage keeps every field
* optional and lets the user clear one to an empty string; applying has to know
* the difference between "set this" and "leave whatever is there alone", and
* every consumer would otherwise re-derive the same trim-and-drop rules.
*/
export interface MaterializedAgentProfile {
provider: string;
/** Empty when the profile names no model, meaning "leave the model alone". */
modelId: string;
modeId: string;
thinkingOptionId: string;
featureValues: Record<string, unknown>;
}
function trimmed(value: string | undefined): string {
return value?.trim() ?? "";
}
export function materializeAgentProfile(profile: AgentProfile): MaterializedAgentProfile {
return {
provider: trimmed(profile.provider),
modelId: trimmed(profile.model),
modeId: trimmed(profile.modeId),
thinkingOptionId: trimmed(profile.thinkingOptionId),
featureValues: profile.featureValues ?? {},
};
}
/**
* 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
* absent because a running agent cannot change the process it is.
*/
export function toAgentConfigApply(profile: MaterializedAgentProfile): AgentConfigApply {
return {
...(profile.modelId ? { modelId: profile.modelId } : {}),
...(profile.modeId ? { modeId: profile.modeId } : {}),
...(profile.thinkingOptionId ? { thinkingOptionId: profile.thinkingOptionId } : {}),
...(Object.keys(profile.featureValues).length > 0
? { featureValues: profile.featureValues }
: {}),
};
}
@@ -0,0 +1,71 @@
import { IDENTITY_COLOR_NAMES, type IdentityColorName } from "@/styles/identity-colors";
/**
* A profile's icon is a key into a fixed registry, not a glyph. The value is
* stored in daemon config and drawn by every client, so it has to survive being
* rendered by an app version that ships a different icon set — an unknown key
* falls back to the default glyph rather than showing the raw string.
*
* Order is what the grid draws, five to a row: related icons sit together so
* scanning the grid is scanning categories, not thirty unrelated shapes.
*/
export const AGENT_PROFILE_ICON_KEYS = [
"code",
"terminal",
"bug",
"wrench",
"hammer",
"flask",
"testTube",
"microscope",
"search",
"eye",
"palette",
"feather",
"pencil",
"fileText",
"book",
"rocket",
"package",
"boxes",
"server",
"database",
"cpu",
"cloud",
"globe",
"gitBranch",
"layers",
"compass",
"brain",
"sparkles",
"shield",
] as const;
export type AgentProfileIconKey = (typeof AGENT_PROFILE_ICON_KEYS)[number];
/** Reuses the host identity palette so a profile and a host name read as one family. */
export type AgentProfileColor = "none" | IdentityColorName;
export const AGENT_PROFILE_COLORS: readonly AgentProfileColor[] = ["none", ...IDENTITY_COLOR_NAMES];
export function isAgentProfileIconKey(value: string | undefined): value is AgentProfileIconKey {
return AGENT_PROFILE_ICON_KEYS.some((key) => key === value);
}
export function isAgentProfileColor(value: string | undefined): value is AgentProfileColor {
return AGENT_PROFILE_COLORS.some((color) => color === value);
}
/** Anything unrecognised means "not chosen": the glyph and colour both default. */
export function resolveAgentProfileIconKey(value: string | undefined): AgentProfileIconKey | null {
return isAgentProfileIconKey(value) ? value : null;
}
export function resolveAgentProfileColor(value: string | undefined): AgentProfileColor {
return isAgentProfileColor(value) ? value : "none";
}
@@ -0,0 +1,431 @@
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { describe, expect, it } from "vitest";
import {
buildFeatureRequestKey,
openAgentProfileForm,
type AgentProfileFormModel,
} from "./profile-form-model";
const CLAUDE: ProviderSnapshotEntry = {
provider: "claude",
status: "ready",
enabled: true,
label: "Claude Code",
defaultModeId: "plan",
modes: [
{ id: "plan", label: "Plan" },
{ id: "accept-edits", label: "Accept edits" },
],
models: [
{
provider: "claude",
id: "claude-opus-5",
label: "Opus 5",
isDefault: true,
thinkingOptions: [
{ id: "think", label: "Think" },
{ id: "think-hard", label: "Think hard" },
],
},
{
provider: "claude",
id: "claude-haiku-4-5",
label: "Haiku 4.5",
thinkingOptions: [{ id: "think", label: "Think" }],
},
],
};
const CODEX: ProviderSnapshotEntry = {
provider: "codex",
status: "ready",
enabled: true,
label: "Codex",
modes: [{ id: "read-only", label: "Read only" }],
models: [{ provider: "codex", id: "gpt-5.2-codex", label: "GPT-5.2 Codex", isDefault: true }],
};
const DISABLED: ProviderSnapshotEntry = {
provider: "pi",
status: "ready",
enabled: false,
label: "Pi",
};
const ENTRIES = [CLAUDE, CODEX, DISABLED];
function openWithCatalog(
snapshot: Parameters<typeof openAgentProfileForm>[0],
): AgentProfileFormModel {
const model = openAgentProfileForm(snapshot);
model.applyProviderCatalog(ENTRIES);
return model;
}
function selectClaude(model: AgentProfileFormModel): void {
model.setProvider("claude", { label: "Claude Code" });
}
function optionValues(options: readonly { value: string }[]): string[] {
return options.map((option) => option.value);
}
describe("openAgentProfileForm", () => {
it("starts a create form empty and cannot submit", () => {
const model = openWithCatalog({ mode: "create" });
const state = model.getState();
expect(state.name).toBe("");
expect(state.provider).toBe("");
expect(state.canSubmit).toBe(false);
expect(state.submitValue).toBeNull();
expect(state.disclosure.showModelField).toBe(false);
});
it("lists only enabled providers", () => {
const model = openWithCatalog({ mode: "create" });
expect(optionValues(model.getState().providerOptions)).toEqual(["claude", "codex"]);
});
it("requires a name and a provider before it can submit", () => {
const model = openWithCatalog({ mode: "create" });
model.setName("UI work");
expect(model.getState().canSubmit).toBe(false);
selectClaude(model);
expect(model.getState().canSubmit).toBe(true);
expect(model.getState().submitValue).toMatchObject({ name: "UI work", provider: "claude" });
});
it("omits blank text fields from the submitted value but never a selection", () => {
const model = openWithCatalog({ mode: "create" });
model.setName(" Cheap grunt ");
model.setProvider("codex", { label: "Codex" });
model.setNotes(" ");
// Codex declares no thinking options, so that key is genuinely absent —
// model and mode are seeded, never left for the host to decide.
expect(model.getState().submitValue).toEqual({
name: "Cheap grunt",
provider: "codex",
model: "gpt-5.2-codex",
modeId: "read-only",
});
});
it("seeds edit mode from the stored profile before the catalog lands", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: {
id: "p1",
name: "UI work",
icon: "palette",
provider: "claude",
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { webSearch: true },
notes: "Visual work only.",
},
});
const state = model.getState();
expect(state.name).toBe("UI work");
expect(state.icon).toBe("palette");
// Ids double as labels until the catalog arrives.
expect(state.modelDisplay).toEqual({ label: "claude-opus-5" });
expect(state.thinkingDisplay).toEqual({ label: "think-hard" });
expect(state.catalogResolution).toBe("idle");
});
it("upgrades seeded displays to catalog labels without touching selections", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: {
id: "p1",
name: "UI work",
provider: "claude",
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
},
});
model.applyProviderCatalog(ENTRIES);
const state = model.getState();
expect(state.providerDisplay).toEqual({ label: "Claude Code" });
expect(state.modelDisplay).toEqual({ label: "Opus 5" });
expect(state.modeDisplay).toEqual({ label: "Plan" });
expect(state.thinkingDisplay).toEqual({ label: "Think hard" });
expect(state.modelId).toBe("claude-opus-5");
expect(state.catalogResolution).toBe("complete");
});
it("keeps a stored value the catalog does not know", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: { id: "p1", name: "Legacy", provider: "claude", model: "claude-retired-3" },
});
model.applyProviderCatalog(ENTRIES);
expect(model.getState().modelId).toBe("claude-retired-3");
expect(model.getState().modelDisplay).toEqual({ label: "claude-retired-3" });
expect(model.getState().disclosure.showModelField).toBe(true);
});
describe("provider cascade", () => {
it("reseeds every provider-scoped selection when the provider changes", () => {
const model = openWithCatalog({ mode: "create" });
model.setName("Test writer");
selectClaude(model);
model.setModel("claude-opus-5", { label: "Opus 5" });
model.setThinking("think-hard", { label: "Think hard" });
model.applyFeatures(model.getState().featureRequestKey ?? "", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
model.setFeatureValue("webSearch", true);
model.setProvider("codex", { label: "Codex" });
const state = model.getState();
// Claude's ids mean nothing under Codex, so each field lands on Codex's own default.
expect(state.modelId).toBe("gpt-5.2-codex");
expect(state.modelDisplay).toEqual({ label: "GPT-5.2 Codex" });
expect(state.thinkingOptionId).toBe("");
expect(state.thinkingDisplay).toBeNull();
expect(state.featureValues).toEqual({});
expect(state.features).toEqual([]);
expect(state.name).toBe("Test writer");
});
it("seeds each provider's default mode", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
expect(model.getState().modeId).toBe("plan");
// Codex declares no defaultModeId, so the first mode it offers stands in.
model.setProvider("codex", { label: "Codex" });
expect(model.getState().modeId).toBe("read-only");
});
it("re-populates model, mode and thinking options from the new provider", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
expect(optionValues(model.getState().modelOptions)).toEqual([
"claude-opus-5",
"claude-haiku-4-5",
]);
expect(optionValues(model.getState().thinkingOptions)).toEqual(["think", "think-hard"]);
model.setProvider("codex", { label: "Codex" });
expect(optionValues(model.getState().modelOptions)).toEqual(["gpt-5.2-codex"]);
expect(optionValues(model.getState().modeOptions)).toEqual(["read-only"]);
// GPT-5.2 Codex declares no thinking options, so the field disappears.
expect(model.getState().thinkingOptions).toEqual([]);
expect(model.getState().disclosure.showThinkingField).toBe(false);
});
it("ignores a no-op provider selection", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
model.setModel("claude-opus-5", { label: "Opus 5" });
selectClaude(model);
expect(model.getState().modelId).toBe("claude-opus-5");
});
});
describe("model cascade", () => {
it("reseeds a thinking level the new model does not offer", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
model.setModel("claude-opus-5", { label: "Opus 5" });
model.setThinking("think-hard", { label: "Think hard" });
// Haiku offers only "think", so "think-hard" cannot survive the switch.
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
expect(model.getState().thinkingOptionId).toBe("think");
expect(model.getState().thinkingDisplay).toEqual({ label: "Think" });
});
it("keeps a thinking level the new model still offers", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
model.setModel("claude-opus-5", { label: "Opus 5" });
model.setThinking("think", { label: "Think" });
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
expect(model.getState().thinkingOptionId).toBe("think");
});
it("seeds the provider's default model and reads thinking options from it", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
expect(model.getState().modelId).toBe("claude-opus-5");
expect(optionValues(model.getState().thinkingOptions)).toEqual(["think", "think-hard"]);
expect(model.getState().thinkingOptionId).toBe("think");
});
it("offers no way to unset a selection", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
const state = model.getState();
const everyOption = [...state.modelOptions, ...state.modeOptions, ...state.thinkingOptions];
expect(everyOption.length).toBeGreaterThan(0);
expect(optionValues(everyOption)).not.toContain("");
});
});
describe("features", () => {
it("asks for features per provider/model/mode/thinking combination", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
expect(model.getState().featureRequest).toEqual({
provider: "claude",
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think",
});
const firstKey = model.getState().featureRequestKey;
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
expect(model.getState().featureRequestKey).not.toBe(firstKey);
expect(model.getState().featureRequest).toEqual({
provider: "claude",
model: "claude-haiku-4-5",
modeId: "plan",
thinkingOptionId: "think",
});
});
it("moves from pending to complete only for the current request", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
const key = model.getState().featureRequestKey ?? "";
expect(model.getState().featureResolution).toBe("pending");
model.applyFeatures("stale-key", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
expect(model.getState().featureResolution).toBe("pending");
expect(model.getState().features).toEqual([]);
model.applyFeatures(key, [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
expect(model.getState().featureResolution).toBe("complete");
expect(model.getState().disclosure.showFeaturesField).toBe(true);
});
it("reflects a set feature value back into the rendered feature", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
model.applyFeatures(model.getState().featureRequestKey ?? "", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
model.setFeatureValue("webSearch", true);
expect(model.getState().features[0]).toMatchObject({ id: "webSearch", value: true });
expect(model.getState().featureValues).toEqual({ webSearch: true });
});
it("keeps stored feature values while the feature list is still pending", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: {
id: "p1",
name: "UI work",
provider: "claude",
featureValues: { webSearch: true },
},
});
model.applyProviderCatalog(ENTRIES);
expect(model.getState().featureResolution).toBe("pending");
expect(model.getState().submitValue?.featureValues).toEqual({ webSearch: true });
});
it("prunes stored values the provider no longer reports", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: {
id: "p1",
name: "UI work",
provider: "claude",
featureValues: { webSearch: true, retiredFlag: true },
},
});
model.applyProviderCatalog(ENTRIES);
model.applyFeatures(model.getState().featureRequestKey ?? "", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
expect(model.getState().featureValues).toEqual({ webSearch: true });
});
it("resolves an unavailable feature listing without showing the field", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
model.applyFeaturesUnavailable(model.getState().featureRequestKey ?? "");
expect(model.getState().featureResolution).toBe("complete");
expect(model.getState().disclosure.showFeaturesField).toBe(false);
});
});
it("blocks submission while a save is in flight", () => {
const model = openWithCatalog({ mode: "create" });
model.setName("UI work");
selectClaude(model);
model.setSubmitting(true);
expect(model.getState().canSubmit).toBe(false);
model.setSubmitting(false);
expect(model.getState().canSubmit).toBe(true);
});
it("stops publishing after close", () => {
const model = openWithCatalog({ mode: "create" });
let notifications = 0;
model.subscribe(() => {
notifications += 1;
});
model.setName("a");
expect(notifications).toBe(1);
model.close();
model.setName("b");
expect(notifications).toBe(1);
expect(model.getState().name).toBe("a");
});
});
describe("buildFeatureRequestKey", () => {
it("is null without a provider", () => {
expect(buildFeatureRequestKey(null)).toBeNull();
});
it("separates the fields so a value cannot bleed across positions", () => {
expect(buildFeatureRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||");
expect(buildFeatureRequestKey({ provider: "claude", modeId: "a" })).not.toBe(
buildFeatureRequestKey({ provider: "claude", model: "a" }),
);
});
});
@@ -0,0 +1,594 @@
import type {
AgentFeature,
AgentMode,
AgentModelDefinition,
AgentSelectOption,
ProviderSnapshotEntry,
} from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
import { applyFeatureValues, pruneFeatureValues } from "@/hooks/feature-preferences";
import { filterSelectableModels } from "@/provider-selection/model-catalog";
/**
* The persisted profile minus the id; the list owns identity.
*
* `Omit` does not work here. `AgentProfileSchema` is `.passthrough()`, so
* `AgentProfile` carries a `[key: string]: unknown` index signature, and
* `Exclude<keyof AgentProfile, "id">` stays `string | number` — the named keys
* come back as `unknown` and `name`/`provider` stop being required. Mapping
* `keyof` with an `as` filter drops the index signature and the id together.
*/
export type AgentProfileValue = {
[K in keyof AgentProfile as string extends K
? never
: K extends "id"
? never
: K]: AgentProfile[K];
};
export interface AgentProfileFormDisplay {
label: string;
description?: string;
}
export interface AgentProfileFormOption {
id: string;
value: string;
label: string;
description?: string;
testID: string;
}
export interface AgentProfileFormSnapshot {
mode: "create" | "edit";
profile?: AgentProfile;
}
/**
* The inputs a feature listing needs. Features are provider-scoped and change
* with model/mode/thinking, so every one of those is part of the request.
*/
export interface AgentProfileFeatureRequest {
provider: string;
model?: string;
modeId?: string;
thinkingOptionId?: string;
}
export type AgentProfileResolutionStatus = "idle" | "pending" | "complete";
export interface AgentProfileFormDisclosure {
showModelField: boolean;
showModeField: boolean;
showThinkingField: boolean;
showFeaturesField: boolean;
}
export interface AgentProfileFormState {
mode: "create" | "edit";
name: string;
icon: string;
color: string;
notes: string;
provider: string;
modelId: string;
modeId: string;
thinkingOptionId: string;
featureValues: Record<string, unknown>;
providerOptions: AgentProfileFormOption[];
modelOptions: AgentProfileFormOption[];
modeOptions: AgentProfileFormOption[];
thinkingOptions: AgentProfileFormOption[];
features: AgentFeature[];
providerDisplay: AgentProfileFormDisplay | null;
modelDisplay: AgentProfileFormDisplay | null;
modeDisplay: AgentProfileFormDisplay | null;
thinkingDisplay: AgentProfileFormDisplay | null;
catalogResolution: AgentProfileResolutionStatus;
featureResolution: AgentProfileResolutionStatus;
featureRequest: AgentProfileFeatureRequest | null;
featureRequestKey: string | null;
disclosure: AgentProfileFormDisclosure;
isSubmitting: boolean;
submitError: string | null;
canSubmit: boolean;
submitValue: AgentProfileValue | null;
}
export interface AgentProfileFormModel {
getState: () => AgentProfileFormState;
subscribe: (listener: () => void) => () => void;
close: () => void;
/** Late input: the host-scoped provider catalog. Never touches selections. */
applyProviderCatalog: (entries: readonly ProviderSnapshotEntry[]) => void;
/** Late input: the feature list for one request. Stale keys are ignored. */
applyFeatures: (requestKey: string, features: readonly AgentFeature[]) => void;
/** Resolve a request that produced no usable features (provider error). */
applyFeaturesUnavailable: (requestKey: string) => void;
setName: (value: string) => void;
setAppearance: (value: { icon: string; color: string }) => void;
setNotes: (value: string) => void;
setProvider: (providerId: string, display: AgentProfileFormDisplay) => void;
setModel: (modelId: string, display: AgentProfileFormDisplay | null) => void;
setMode: (modeId: string, display: AgentProfileFormDisplay | null) => void;
setThinking: (thinkingOptionId: string, display: AgentProfileFormDisplay | null) => void;
setFeatureValue: (featureId: string, value: unknown) => void;
setSubmitting: (value: boolean) => void;
setSubmitError: (value: string | null) => void;
}
function findEntry(
entries: readonly ProviderSnapshotEntry[],
provider: string,
): ProviderSnapshotEntry | null {
if (!provider) {
return null;
}
return entries.find((entry) => entry.provider === provider) ?? null;
}
function resolveModels(
entries: readonly ProviderSnapshotEntry[],
provider: string,
): AgentModelDefinition[] {
return filterSelectableModels(findEntry(entries, provider)?.models ?? null) ?? [];
}
function resolveModes(entries: readonly ProviderSnapshotEntry[], provider: string): AgentMode[] {
return findEntry(entries, provider)?.modes ?? [];
}
/**
* Thinking options hang off a model, so an unset model still has to resolve to
* the model the daemon would pick — otherwise the field disappears whenever the
* user leaves the model on "provider default".
*/
function resolveEffectiveModel(
models: readonly AgentModelDefinition[],
modelId: string,
): AgentModelDefinition | null {
const trimmed = modelId.trim();
if (trimmed) {
return models.find((model) => model.id === trimmed) ?? null;
}
return models.find((model) => model.isDefault) ?? models[0] ?? null;
}
function resolveThinkingOptions(
entries: readonly ProviderSnapshotEntry[],
provider: string,
modelId: string,
): AgentSelectOption[] {
return resolveEffectiveModel(resolveModels(entries, provider), modelId)?.thinkingOptions ?? [];
}
/** Every real option is selected by its own id; only the unset row differs. */
function formOption(input: {
id: string;
label: string;
description: string | undefined;
testID: string;
}): AgentProfileFormOption {
return {
id: input.id,
value: input.id,
label: input.label,
...(input.description ? { description: input.description } : {}),
testID: input.testID,
};
}
function buildProviderOptions(entries: readonly ProviderSnapshotEntry[]): AgentProfileFormOption[] {
return entries
.filter((entry) => entry.enabled)
.map((entry) =>
formOption({
id: entry.provider,
label: entry.label ?? entry.provider,
description: entry.description,
testID: `agent-profile-provider-option-${entry.provider}`,
}),
);
}
function buildModelOptions(models: readonly AgentModelDefinition[]): AgentProfileFormOption[] {
return models.map((model) =>
formOption({
id: model.id,
label: model.label,
// Models are labelled by family, so the id is the only thing that
// distinguishes two entries with the same label.
description: model.description ?? model.id,
testID: `agent-profile-model-option-${model.id}`,
}),
);
}
function buildModeOptions(modes: readonly AgentMode[]): AgentProfileFormOption[] {
return modes.map((mode) =>
formOption({
id: mode.id,
label: formatAgentModeLabel(mode),
description: mode.description,
testID: `agent-profile-mode-option-${mode.id}`,
}),
);
}
function buildThinkingOptions(options: readonly AgentSelectOption[]): AgentProfileFormOption[] {
return options.map((option) =>
formOption({
id: option.id,
label: formatThinkingOptionLabel(option),
description: option.description,
testID: `agent-profile-thinking-option-${option.id}`,
}),
);
}
/**
* Every selection resolves to a concrete id.
*
* Each list used to carry a synthetic "Provider default" row. It read as a value
* you could choose but stored an absent one, so a profile that looked fully
* specified would apply whatever the host preferred at the time — and two hosts
* would materialize the same profile differently. The schedule form never
* offered that row; this form now matches it and seeds the catalog's own default
* instead, so what the form shows is what the profile stores.
*/
function defaultModelId(models: readonly AgentModelDefinition[]): string {
return models.find((model) => model.isDefault)?.id ?? models[0]?.id ?? "";
}
function defaultModeId(entry: ProviderSnapshotEntry | null, modes: readonly AgentMode[]): string {
return entry?.defaultModeId ?? modes[0]?.id ?? "";
}
function defaultThinkingOptionId(model: AgentModelDefinition | null): string {
const options = model?.thinkingOptions ?? [];
return (
model?.defaultThinkingOptionId ??
options.find((option) => option.isDefault)?.id ??
options[0]?.id ??
""
);
}
function seedSelections(
next: AgentProfileFormState,
input: {
entry: ProviderSnapshotEntry | null;
models: readonly AgentModelDefinition[];
modes: readonly AgentMode[];
},
): AgentProfileFormState {
const modelId = next.modelId || defaultModelId(input.models);
const modeId = next.modeId || defaultModeId(input.entry, input.modes);
const thinkingOptionId =
next.thinkingOptionId || defaultThinkingOptionId(resolveEffectiveModel(input.models, modelId));
if (
modelId === next.modelId &&
modeId === next.modeId &&
thinkingOptionId === next.thinkingOptionId
) {
return next;
}
return { ...next, modelId, modeId, thinkingOptionId };
}
/**
* Selected labels upgrade from the stored id to the catalog label once the
* catalog lands, and fall back to the id when the catalog does not know the
* value. They never blank out — a profile pointing at a retired model still
* reads as that model.
*/
function refineDisplay(
current: AgentProfileFormDisplay | null,
selectedId: string,
resolvedLabel: string | null,
): AgentProfileFormDisplay | null {
if (!selectedId) {
return null;
}
if (resolvedLabel) {
return { label: resolvedLabel };
}
return current ?? { label: selectedId };
}
function buildFeatureRequest(state: AgentProfileFormState): AgentProfileFeatureRequest | null {
if (!state.provider) {
return null;
}
return {
provider: state.provider,
...(state.modelId ? { model: state.modelId } : {}),
...(state.modeId ? { modeId: state.modeId } : {}),
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
};
}
export function buildFeatureRequestKey(request: AgentProfileFeatureRequest | null): string | null {
if (!request) {
return null;
}
return [
request.provider,
request.model ?? "",
request.modeId ?? "",
request.thinkingOptionId ?? "",
].join("|");
}
function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | null {
const name = state.name.trim();
const notes = state.notes.trim();
if (!name || !state.provider) {
return null;
}
return {
name,
...(state.icon ? { icon: state.icon } : {}),
...(state.color ? { color: state.color } : {}),
provider: state.provider,
...(state.modelId ? { model: state.modelId } : {}),
...(state.modeId ? { modeId: state.modeId } : {}),
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
...(Object.keys(state.featureValues).length > 0 ? { featureValues: state.featureValues } : {}),
...(notes ? { notes } : {}),
};
}
function resolveFeatureStatus(
featureRequestKey: string | null,
featuresAreCurrent: boolean,
): AgentProfileResolutionStatus {
if (featureRequestKey === null) {
return "idle";
}
return featuresAreCurrent ? "complete" : "pending";
}
const BLANK_PROFILE: AgentProfileValue = { name: "", provider: "" };
/** A stored id doubles as its own label until the catalog can upgrade it. */
function seedDisplay(value: string | undefined): AgentProfileFormDisplay | null {
return value ? { label: value } : null;
}
/**
* Edit mode seeds every value AND display from the stored profile alone: the
* catalog is not loaded yet, so `applyProviderCatalog` does the upgrading later.
*/
function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileFormState {
const profile = snapshot.profile ?? BLANK_PROFILE;
return {
mode: snapshot.mode,
name: profile.name,
icon: profile.icon ?? "",
color: profile.color ?? "",
notes: profile.notes ?? "",
provider: profile.provider,
modelId: profile.model ?? "",
modeId: profile.modeId ?? "",
thinkingOptionId: profile.thinkingOptionId ?? "",
featureValues: { ...profile.featureValues },
providerOptions: [],
modelOptions: [],
modeOptions: [],
thinkingOptions: [],
features: [],
providerDisplay: seedDisplay(profile.provider),
modelDisplay: seedDisplay(profile.model),
modeDisplay: seedDisplay(profile.modeId),
thinkingDisplay: seedDisplay(profile.thinkingOptionId),
catalogResolution: "idle",
featureResolution: "idle",
featureRequest: null,
featureRequestKey: null,
disclosure: {
showModelField: false,
showModeField: false,
showThinkingField: false,
showFeaturesField: false,
},
isSubmitting: false,
submitError: null,
canSubmit: false,
submitValue: null,
};
}
export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentProfileFormModel {
let entries: readonly ProviderSnapshotEntry[] = [];
let catalogResolution: AgentProfileResolutionStatus = "idle";
let resolvedFeatureKey: string | null = null;
let resolvedFeatures: AgentFeature[] = [];
let listeners = new Set<() => void>();
let closed = false;
function derive(incoming: AgentProfileFormState): AgentProfileFormState {
const models = resolveModels(entries, incoming.provider);
const modes = resolveModes(entries, incoming.provider);
const next = seedSelections(incoming, {
entry: findEntry(entries, incoming.provider),
models,
modes,
});
const thinking = resolveThinkingOptions(entries, next.provider, next.modelId);
const featureRequest = buildFeatureRequest(next);
const featureRequestKey = buildFeatureRequestKey(featureRequest);
const featuresAreCurrent =
featureRequestKey !== null && featureRequestKey === resolvedFeatureKey;
const features = featuresAreCurrent
? applyFeatureValues(resolvedFeatures, next.featureValues)
: [];
const featureResolution = resolveFeatureStatus(featureRequestKey, featuresAreCurrent);
const withOptions: AgentProfileFormState = {
...next,
providerOptions: buildProviderOptions(entries),
modelOptions: buildModelOptions(models),
modeOptions: buildModeOptions(modes),
thinkingOptions: buildThinkingOptions(thinking),
features,
providerDisplay: refineDisplay(
next.providerDisplay,
next.provider,
findEntry(entries, next.provider)?.label ?? null,
),
modelDisplay: refineDisplay(
next.modelDisplay,
next.modelId,
models.find((model) => model.id === next.modelId)?.label ?? null,
),
modeDisplay: refineDisplay(
next.modeDisplay,
next.modeId,
(() => {
const mode = modes.find((entry) => entry.id === next.modeId);
return mode ? formatAgentModeLabel(mode) : null;
})(),
),
thinkingDisplay: refineDisplay(
next.thinkingDisplay,
next.thinkingOptionId,
(() => {
const option = thinking.find((entry) => entry.id === next.thinkingOptionId);
return option ? formatThinkingOptionLabel(option) : null;
})(),
),
catalogResolution,
featureResolution,
featureRequest,
featureRequestKey,
};
// A field appears once the catalog can populate it, and stays visible while
// a stored value needs a home even if this host cannot resolve it.
const hasProvider = Boolean(withOptions.provider);
const disclosure: AgentProfileFormDisclosure = {
showModelField: hasProvider && (models.length > 0 || Boolean(withOptions.modelId)),
showModeField: hasProvider && (modes.length > 0 || Boolean(withOptions.modeId)),
showThinkingField:
hasProvider && (thinking.length > 0 || Boolean(withOptions.thinkingOptionId)),
showFeaturesField: hasProvider && features.length > 0,
};
const canSubmit =
withOptions.name.trim().length > 0 &&
withOptions.provider.length > 0 &&
!withOptions.isSubmitting;
const resolved: AgentProfileFormState = { ...withOptions, disclosure, canSubmit };
return { ...resolved, submitValue: canSubmit ? buildSubmitValue(resolved) : null };
}
let state: AgentProfileFormState = derive(buildInitialState(snapshot));
function publish(mutate: (current: AgentProfileFormState) => AgentProfileFormState): void {
if (closed) {
return;
}
state = derive(mutate(state));
for (const listener of listeners) {
listener();
}
}
return {
getState: () => state,
subscribe: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
close: () => {
closed = true;
listeners = new Set();
},
applyProviderCatalog: (nextEntries) => {
entries = [...nextEntries];
catalogResolution = "complete";
publish((current) => current);
},
applyFeatures: (requestKey, features) => {
if (requestKey !== state.featureRequestKey) {
return;
}
resolvedFeatureKey = requestKey;
resolvedFeatures = [...features];
publish((current) => ({
...current,
featureValues: pruneFeatureValues(current.featureValues, resolvedFeatures),
}));
},
applyFeaturesUnavailable: (requestKey) => {
if (requestKey !== state.featureRequestKey) {
return;
}
resolvedFeatureKey = requestKey;
resolvedFeatures = [];
publish((current) => current);
},
setName: (value) => publish((current) => ({ ...current, name: value })),
setAppearance: (value) =>
publish((current) => ({ ...current, icon: value.icon, color: value.color })),
setNotes: (value) => publish((current) => ({ ...current, notes: value })),
setProvider: (providerId, display) =>
publish((current) => {
if (current.provider === providerId) {
return current;
}
// Everything below the provider is provider-scoped: a model id, mode id,
// thinking id or feature id from the old provider means nothing here.
// Clearing them is enough — `derive` seeds the new provider's defaults.
return {
...current,
provider: providerId,
providerDisplay: display,
modelId: "",
modelDisplay: null,
modeId: "",
modeDisplay: null,
thinkingOptionId: "",
thinkingDisplay: null,
featureValues: {},
};
}),
setModel: (modelId, display) =>
publish((current) => {
if (current.modelId === modelId) {
return current;
}
// Thinking options are a property of the model, so a level the new model
// does not offer has to go.
const nextThinking = resolveThinkingOptions(entries, current.provider, modelId);
const keepsThinking =
current.thinkingOptionId.length > 0 &&
nextThinking.some((option) => option.id === current.thinkingOptionId);
return {
...current,
modelId,
modelDisplay: display,
thinkingOptionId: keepsThinking ? current.thinkingOptionId : "",
thinkingDisplay: keepsThinking ? current.thinkingDisplay : null,
};
}),
setMode: (modeId, display) =>
publish((current) => ({ ...current, modeId, modeDisplay: display })),
setThinking: (thinkingOptionId, display) =>
publish((current) => ({ ...current, thinkingOptionId, thinkingDisplay: display })),
setFeatureValue: (featureId, value) =>
publish((current) => ({
...current,
featureValues: { ...current.featureValues, [featureId]: value },
})),
setSubmitting: (value) => publish((current) => ({ ...current, isSubmitting: value })),
setSubmitError: (value) => publish((current) => ({ ...current, submitError: value })),
};
}
@@ -0,0 +1,56 @@
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
export interface AgentProfileTag {
id: string;
label: string;
}
function findEntry(
entries: readonly ProviderSnapshotEntry[],
provider: string,
): ProviderSnapshotEntry | null {
return entries.find((entry) => entry.provider === provider) ?? null;
}
/**
* The one-line résumé of a profile in the settings list. Every label falls back
* to the stored id: a row must stay readable on a host whose provider catalog
* is offline, or that no longer ships the model the profile names.
*/
export function buildAgentProfileTags(input: {
profile: AgentProfile;
entries: readonly ProviderSnapshotEntry[] | undefined;
formatFeatureCount: (count: number) => string;
}): AgentProfileTag[] {
const entries = input.entries ?? [];
const entry = findEntry(entries, input.profile.provider);
const tags: AgentProfileTag[] = [
{ id: "provider", label: entry?.label ?? input.profile.provider },
];
const modelId = input.profile.model?.trim();
if (modelId) {
const model = entry?.models?.find((candidate) => candidate.id === modelId);
tags.push({ id: "model", label: model?.label ?? modelId });
}
const modeId = input.profile.modeId?.trim();
if (modeId) {
const mode = entry?.modes?.find((candidate) => candidate.id === modeId);
tags.push({ id: "mode", label: mode ? formatAgentModeLabel(mode) : modeId });
}
const thinkingOptionId = input.profile.thinkingOptionId?.trim();
if (thinkingOptionId) {
tags.push({ id: "thinking", label: formatThinkingOptionLabel({ id: thinkingOptionId }) });
}
const featureCount = Object.keys(input.profile.featureValues ?? {}).length;
if (featureCount > 0) {
tags.push({ id: "features", label: input.formatFeatureCount(featureCount) });
}
return tags;
}
@@ -0,0 +1,180 @@
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";
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, toAgentConfigApply } from "./materialize-profile";
import { buildAgentProfileTags } from "./profile-summary";
import { useAgentProfiles } from "./use-agent-profiles";
/** The draft composer's own setters. Applying a profile drives them like a user would. */
export interface DraftAgentProfileControls {
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 }
| { kind: "draft"; controls: DraftAgentProfileControls };
/** Everything the model picker renders for one profile. It never sees the profile itself. */
export interface AgentProfilePickerRow {
id: string;
provider: string;
/** Icon registry key and identity colour; either may be empty for the default glyph. */
icon: string;
color: string;
name: string;
/** "Claude Code · Opus 5 · Plan · Think hard" */
summary: string;
}
export interface AgentProfilePicker {
rows: AgentProfilePickerRow[];
applyProfile: (profileId: string) => void;
}
export interface UseAgentProfilePickerInput {
serverId: string | null;
/**
* Providers this composer can actually run; pass a stable reference. A profile
* naming anything else is hidden rather than shown as a row that cannot do
* what it says — a live agent is one provider's process and cannot switch, and
* the draft form ignores a provider the host does not offer.
*/
availableProviders: readonly string[];
target: AgentProfileApplyTarget;
}
/**
* The agent-profiles section of the model picker: the rows to draw, and what
* pressing one does. A supported host returns an empty row list when there is
* nothing applicable so the picker can retain its settings shortcut without
* pinning an empty section. Unsupported or still-loading hosts return `null`.
*/
export function useAgentProfilePicker(
input: UseAgentProfilePickerInput,
): AgentProfilePicker | null {
const { serverId, availableProviders, target } = input;
const { t } = useTranslation();
const { profiles, isSupported } = useAgentProfiles(serverId);
// Profiles are host config, so their labels read from the host-wide catalog
// rather than a workspace's. That is also the key the settings section uses,
// so every composer on a host shares one query instead of adding its own.
const { entries } = useProvidersSnapshot(serverId, { cwd: null });
const { updatePreferences } = useFormPreferences();
const client = useSessionStore((state) => state.sessions[serverId ?? ""]?.client ?? null);
const toast = useToast();
const applicableProfiles = useMemo(() => {
if (!isSupported || !profiles) {
return [];
}
const available = new Set(availableProviders);
return profiles.filter((profile) => available.has(profile.provider));
}, [availableProviders, isSupported, profiles]);
const formatFeatureCount = useCallback(
(count: number) =>
count === 1
? t("settings.host.agentProfiles.featureCountOne", { count })
: t("settings.host.agentProfiles.featureCount", { count }),
[t],
);
const rows = useMemo<AgentProfilePickerRow[]>(
() =>
applicableProfiles.map((profile) => ({
id: profile.id,
provider: profile.provider,
icon: profile.icon ?? "",
color: profile.color ?? "",
name: profile.name,
summary: buildAgentProfileTags({ profile, entries, formatFeatureCount })
.map((tag) => tag.label)
.join(" · "),
})),
[applicableProfiles, entries, formatFeatureCount],
);
const persistSelection = useCallback(
(profile: AgentProfile) => {
const resolved = materializeAgentProfile(profile);
void updatePreferences((current) =>
mergeCreateAgentSelectionPreferences({
preferences: current,
provider: resolved.provider,
modelId: resolved.modelId,
modeId: resolved.modeId,
thinkingOptionId: resolved.thinkingOptionId,
...(Object.keys(resolved.featureValues).length > 0
? { featureValues: resolved.featureValues }
: {}),
}),
).catch((error) => {
console.warn("[useAgentProfilePicker] persist profile selection failed", error);
});
},
[updatePreferences],
);
const applyProfile = useCallback(
(profileId: string) => {
const profile = applicableProfiles.find((entry) => entry.id === profileId);
if (!profile) {
return;
}
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") {
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;
}
if (!client) {
return;
}
void client
.applyAgentConfig(target.agentId, toAgentConfigApply(resolved))
.then((notice) => showProviderNoticeToast(toast, notice))
.catch((error) => {
console.warn("[useAgentProfilePicker] applyAgentConfig failed", error);
toast.error(toErrorMessage(error));
});
},
[applicableProfiles, client, persistSelection, target, toast],
);
return useMemo(
() => (isSupported && profiles !== null ? { rows, applyProfile } : null),
[applyProfile, isSupported, profiles, rows],
);
}
@@ -0,0 +1,34 @@
import { useCallback } from "react";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useSessionStore } from "@/stores/session-store";
import { supportsAgentProfiles } from "./capabilities";
export interface UseAgentProfilesResult {
/** `null` until the daemon config has arrived. */
profiles: AgentProfile[] | null;
/** False on daemons that predate agent profiles, or while disconnected. */
isSupported: boolean;
/** Writes the whole list; there is no per-profile RPC. */
saveProfiles: (next: AgentProfile[]) => Promise<void>;
}
export function useAgentProfiles(serverId: string | null): UseAgentProfilesResult {
const { config, patchConfig } = useDaemonConfig(serverId);
const isSupported = useSessionStore((state) => {
return supportsAgentProfiles(state.sessions[serverId ?? ""]?.serverInfo?.features);
});
const saveProfiles = useCallback(
async (next: AgentProfile[]) => {
await patchConfig({ agentProfiles: next });
},
[patchConfig],
);
return {
profiles: config ? (config.agentProfiles ?? []) : null,
isSupported,
saveProfiles,
};
}
@@ -0,0 +1,78 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useFetchQuery } from "@/data/query";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { AgentProfileFormModel, AgentProfileFormState } from "./profile-form-model";
/**
* Agent profiles are host-wide, so the catalog is probed in the daemon's home
* directory (`cwd: null` for the snapshot, `~` for the feature listing) rather
* than in a workspace. A profile that resolved against one project's directory
* would name models the next project cannot launch.
*/
const HOME_SCOPE_CWD = "~";
export function useAgentProfileFormCatalog(input: {
serverId: string;
model: AgentProfileFormModel;
}): void {
const { entries } = useProvidersSnapshot(input.serverId, { cwd: null });
useEffect(() => {
if (!entries) {
return;
}
input.model.applyProviderCatalog(entries);
}, [entries, input.model]);
}
export function useAgentProfileFormFeatures(input: {
serverId: string;
model: AgentProfileFormModel;
state: AgentProfileFormState;
}): void {
const { t } = useTranslation();
const client = useHostRuntimeClient(input.serverId);
const isConnected = useHostRuntimeIsConnected(input.serverId);
const request = input.state.featureRequest;
const requestKey = input.state.featureRequestKey;
// `dataShape: "value"` and not `"list"`: a list shape keeps the previous
// page's data while the next request is in flight, which would hand the
// features of one provider/model combination to the key of another.
const featuresQuery = useFetchQuery({
queryKey: ["agentProfileFeatures", input.serverId, requestKey],
dataShape: "value",
staleTimeMs: 5 * 60 * 1000,
enabled: Boolean(client && isConnected && request),
retry: false,
queryFn: async () => {
if (!client || !request) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const payload = await client.listProviderFeatures({ ...request, cwd: HOME_SCOPE_CWD });
if (payload.error) {
throw new Error(payload.error);
}
return payload.features ?? [];
},
});
const { data, isError } = featuresQuery;
const { model } = input;
useEffect(() => {
if (!requestKey || !data) {
return;
}
model.applyFeatures(requestKey, data);
}, [data, model, requestKey]);
useEffect(() => {
if (!requestKey || !isError) {
return;
}
model.applyFeaturesUnavailable(requestKey);
}, [isError, model, requestKey]);
}
@@ -0,0 +1,29 @@
import { useEffect, useState, useSyncExternalStore } from "react";
import {
openAgentProfileForm,
type AgentProfileFormModel,
type AgentProfileFormSnapshot,
type AgentProfileFormState,
} from "./profile-form-model";
/**
* One model per mount. The modal keys its body on mode + profile id, so create
* and edit never share an instance and reopening always starts from the record.
*/
export function useAgentProfileFormModel(
snapshot: AgentProfileFormSnapshot,
): AgentProfileFormModel {
const [model] = useState(() => openAgentProfileForm(snapshot));
useEffect(() => {
return () => {
model.close();
};
}, [model]);
return model;
}
export function useAgentProfileFormState(model: AgentProfileFormModel): AgentProfileFormState {
return useSyncExternalStore(model.subscribe, model.getState, model.getState);
}
@@ -0,0 +1,199 @@
import { describe, expect, it } from "vitest";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { LegacyFavoriteProfileMigration } from ".";
class MemoryStorage {
private readonly values = new Map<string, string>();
constructor(initial: Record<string, unknown> = {}) {
for (const [key, value] of Object.entries(initial)) {
this.values.set(key, JSON.stringify(value));
}
}
async getItem(key: string): Promise<string | null> {
return this.values.get(key) ?? null;
}
async setItem(key: string, value: string): Promise<void> {
this.values.set(key, value);
}
}
class FakeProfileHost {
readonly patches: AgentProfile[][] = [];
config: { agentProfiles?: AgentProfile[] };
shouldFailPatch = false;
shouldFailCatalog = false;
catalogLoadingReads = 0;
constructor(
profiles: AgentProfile[] = [],
readonly supported = true,
) {
this.config = { agentProfiles: profiles };
}
getLastServerInfoMessage() {
return {
features: this.supported
? { agentProfiles: true, agentConfigApply: true }
: { agentProfiles: false, agentConfigApply: false },
};
}
async getDaemonConfig() {
return { requestId: "config", config: this.config };
}
async patchDaemonConfig(patch: { agentProfiles?: AgentProfile[] }) {
if (this.shouldFailPatch) {
throw new Error("host write failed");
}
const profiles = patch.agentProfiles ?? [];
this.patches.push(profiles);
this.config = { ...this.config, agentProfiles: profiles };
return { requestId: "patch", config: this.config };
}
async getProvidersSnapshot() {
if (this.shouldFailCatalog) {
throw new Error("catalog unavailable");
}
if (this.catalogLoadingReads > 0) {
this.catalogLoadingReads -= 1;
return {
entries: [
{
provider: "claude" as const,
status: "loading" as const,
enabled: true,
},
],
generatedAt: new Date(0).toISOString(),
requestId: "providers-loading",
};
}
return {
entries: [
{
provider: "claude" as const,
status: "ready" as const,
enabled: true,
label: "Claude Code",
models: [
{ provider: "claude" as const, id: "opus", label: "Opus" },
{ provider: "claude" as const, id: "sonnet", label: "Sonnet" },
],
},
],
generatedAt: new Date(0).toISOString(),
requestId: "providers",
};
}
}
const PREFERENCES_KEY = "@paseo:create-agent-preferences";
function migrationStorage() {
return new MemoryStorage({
[PREFERENCES_KEY]: {
favoriteModels: [
{ provider: "claude", modelId: "opus" },
{ provider: "claude", modelId: "sonnet" },
{ provider: "claude", modelId: "opus" },
],
},
});
}
describe("legacy favourite profile migration", () => {
it("appends provider-and-model-only profiles with useful catalog names", async () => {
const existing: AgentProfile = {
id: "hand-written",
name: "My Opus",
provider: "claude",
model: "opus",
modeId: "plan",
};
const host = new FakeProfileHost([existing]);
const migration = new LegacyFavoriteProfileMigration(migrationStorage());
await migration.migrateHost("host-a", host);
expect(host.patches).toEqual([
[
existing,
{
id: "legacy_favorite:claude:sonnet",
name: "Sonnet",
provider: "claude",
model: "sonnet",
},
],
]);
});
it("runs once per host, even if the imported profiles are later deleted", async () => {
const storage = migrationStorage();
const migration = new LegacyFavoriteProfileMigration(storage);
const firstHost = new FakeProfileHost();
await migration.migrateHost("host-a", firstHost);
firstHost.config = { agentProfiles: [] };
await migration.migrateHost("host-a", firstHost);
const secondHost = new FakeProfileHost();
await migration.migrateHost("host-b", secondHost);
expect(firstHost.patches).toHaveLength(1);
expect(secondHost.patches).toHaveLength(1);
});
it("waits for profile capabilities without consuming the host migration", async () => {
const storage = migrationStorage();
const migration = new LegacyFavoriteProfileMigration(storage);
const oldHost = new FakeProfileHost([], false);
await migration.migrateHost("host-a", oldHost);
const upgradedHost = new FakeProfileHost();
await migration.migrateHost("host-a", upgradedHost);
expect(oldHost.patches).toHaveLength(0);
expect(upgradedHost.patches).toHaveLength(1);
});
it("retries after a failed host write", async () => {
const host = new FakeProfileHost();
const migration = new LegacyFavoriteProfileMigration(migrationStorage());
host.shouldFailPatch = true;
await expect(migration.migrateHost("host-a", host)).rejects.toThrow("host write failed");
host.shouldFailPatch = false;
await migration.migrateHost("host-a", host);
expect(host.patches).toHaveLength(1);
});
it("retries catalog resolution instead of permanently storing raw ids", async () => {
const host = new FakeProfileHost();
const migration = new LegacyFavoriteProfileMigration(migrationStorage());
host.shouldFailCatalog = true;
await expect(migration.migrateHost("host-a", host)).rejects.toThrow("catalog unavailable");
host.shouldFailCatalog = false;
await migration.migrateHost("host-a", host);
expect(host.patches[0]?.map((profile) => profile.name)).toEqual(["Opus", "Sonnet"]);
});
it("waits for relevant provider models to finish loading", async () => {
const host = new FakeProfileHost();
const migration = new LegacyFavoriteProfileMigration(migrationStorage());
host.catalogLoadingReads = 1;
await migration.migrateHost("host-a", host);
expect(host.patches[0]?.map((profile) => profile.name)).toEqual(["Opus", "Sonnet"]);
});
});
@@ -0,0 +1,231 @@
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";
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;
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>;
}
export interface LegacyFavoriteMigrationHost {
getLastServerInfoMessage(): {
features?: { agentProfiles?: boolean; agentConfigApply?: boolean };
} | null;
getDaemonConfig(): Promise<{ config: { agentProfiles?: AgentProfile[] } }>;
patchDaemonConfig(patch: {
agentProfiles: AgentProfile[];
}): Promise<{ config: { agentProfiles?: AgentProfile[] } }>;
getProvidersSnapshot(): Promise<{ entries: ProviderSnapshotEntry[] }>;
}
function supportsMigration(host: LegacyFavoriteMigrationHost): boolean {
const features = host.getLastServerInfoMessage()?.features;
return features?.agentProfiles === true && features.agentConfigApply === true;
}
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}`;
}
function migratedProfileId(favorite: LegacyFavorite): string {
return `legacy_favorite:${encodeURIComponent(favorite.provider)}:${encodeURIComponent(favorite.modelId)}`;
}
function deduplicateFavorites(favorites: readonly LegacyFavorite[]): LegacyFavorite[] {
const seen = new Set<string>();
return favorites.filter((favorite) => {
const key = favoriteKey(favorite);
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function findCatalogLabels(
favorite: LegacyFavorite,
entries: readonly ProviderSnapshotEntry[],
): { model: string; provider: string } {
const provider = entries.find((entry) => entry.provider === favorite.provider);
const model = provider?.models?.find((entry) => entry.id === favorite.modelId);
return {
model: model?.label?.trim() || favorite.modelId,
provider: provider?.label?.trim() || favorite.provider,
};
}
function uniqueProfileName(input: {
favorite: LegacyFavorite;
entries: readonly ProviderSnapshotEntry[];
usedNames: Set<string>;
}): string {
const labels = findCatalogLabels(input.favorite, input.entries);
const candidates = [
labels.model,
`${labels.provider} · ${labels.model}`,
`${labels.provider} · ${labels.model} (${input.favorite.modelId})`,
];
for (const candidate of candidates) {
if (!input.usedNames.has(candidate)) {
input.usedNames.add(candidate);
return candidate;
}
}
let suffix = 2;
while (input.usedNames.has(`${candidates[2]} ${suffix}`)) {
suffix += 1;
}
const name = `${candidates[2]} ${suffix}`;
input.usedNames.add(name);
return name;
}
function buildMigratedProfiles(input: {
favorites: readonly LegacyFavorite[];
existing: readonly AgentProfile[];
entries: readonly ProviderSnapshotEntry[];
}): AgentProfile[] {
const existingSelections = new Set(
input.existing.map((profile) =>
favoriteKey({ provider: profile.provider, modelId: profile.model?.trim() ?? "" }),
),
);
const existingIds = new Set(input.existing.map((profile) => profile.id));
const usedNames = new Set(input.existing.map((profile) => profile.name));
return deduplicateFavorites(input.favorites).flatMap((favorite) => {
const id = migratedProfileId(favorite);
if (existingSelections.has(favoriteKey(favorite)) || existingIds.has(id)) {
return [];
}
return [
{
id,
name: uniqueProfileName({ favorite, entries: input.entries, usedNames }),
provider: favorite.provider,
model: favorite.modelId,
},
];
});
}
function catalogIsLoading(
entries: readonly ProviderSnapshotEntry[],
favorites: readonly LegacyFavorite[],
): boolean {
const providers = new Set(favorites.map((favorite) => favorite.provider));
return entries.some((entry) => providers.has(entry.provider) && entry.status === "loading");
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function readProviderCatalog(
host: LegacyFavoriteMigrationHost,
favorites: readonly LegacyFavorite[],
): Promise<ProviderSnapshotEntry[]> {
for (const retryDelay of CATALOG_LOADING_RETRY_DELAYS_MS) {
const entries = (await host.getProvidersSnapshot()).entries;
if (!catalogIsLoading(entries, favorites)) {
return entries;
}
await delay(retryDelay);
}
const entries = (await host.getProvidersSnapshot()).entries;
if (catalogIsLoading(entries, favorites)) {
throw new Error("Provider catalog remained loading during legacy favourite migration");
}
return entries;
}
/**
* One-time bridge from device-local model favourites to host-wide profiles.
* The module owns every migration invariant; callers only announce a connected
* host and may safely call again after reconnects or React remounts.
*/
export class LegacyFavoriteProfileMigration {
private readonly inFlight = new Map<string, Promise<void>>();
constructor(private readonly storage: LegacyFavoriteMigrationStorage) {}
migrateHost(serverId: string, host: LegacyFavoriteMigrationHost): Promise<void> {
const active = this.inFlight.get(serverId);
if (active) {
return active;
}
const migration = this.run(serverId, host).finally(() => {
if (this.inFlight.get(serverId) === migration) {
this.inFlight.delete(serverId);
}
});
this.inFlight.set(serverId, migration);
return migration;
}
private async run(serverId: string, host: LegacyFavoriteMigrationHost): Promise<void> {
if (!supportsMigration(host)) {
return;
}
const markerKey = completionKey(serverId);
if ((await this.storage.getItem(markerKey)) === "1") {
return;
}
const favorites = parseStoredPreferences(await this.storage.getItem(PREFERENCES_KEY));
if (favorites.length === 0) {
await this.storage.setItem(markerKey, "1");
return;
}
const [{ config }, entries] = await Promise.all([
host.getDaemonConfig(),
readProviderCatalog(host, favorites),
]);
const existing = config.agentProfiles ?? [];
const migrated = buildMigratedProfiles({ favorites, existing, entries });
if (migrated.length > 0) {
await host.patchDaemonConfig({ agentProfiles: [...existing, ...migrated] });
}
await this.storage.setItem(markerKey, "1");
}
}
export const legacyFavoriteProfileMigration = new LegacyFavoriteProfileMigration(AsyncStorage);
@@ -0,0 +1,384 @@
import { useCallback, useMemo, useRef, useState, type ReactElement } from "react";
import { Pressable, View, type PressableStateCallbackType } from "react-native";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Check, ChevronDown } from "lucide-react-native";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import {
createControlGeometry,
resolveControlInteractionStyles,
type FieldControlSize,
} from "@/components/ui/control-geometry";
import { Field } from "@/components/ui/form-field";
import { identityForeground } from "@/styles/identity-colors";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { AgentProfileGlyph } from "../internal/agent-profile-glyph";
import {
AGENT_PROFILE_COLORS,
AGENT_PROFILE_ICON_KEYS,
resolveAgentProfileColor,
resolveAgentProfileIconKey,
type AgentProfileColor,
type AgentProfileIconKey,
} from "../internal/profile-appearance";
const CELL_SIZE = 40;
const CELL_GAP = 4;
const GRID_COLUMNS = 5;
const CELL_ROW_WIDTH = GRID_COLUMNS * CELL_SIZE + (GRID_COLUMNS - 1) * CELL_GAP;
/** Matches `styles.body.padding`; both derive from `theme.spacing[3]`. */
const CELL_GRID_PADDING = 12;
/** Swatches are dots, not targets the eye has to land on precisely, so they pack tighter. */
const COLOR_CELL_SIZE = 32;
const SWATCH_SIZE = 20;
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedCheck = withUnistyles(Check);
const foregroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const onIdentityMapping = (theme: Theme) => ({ color: theme.colors.background });
/**
* Locked, not a floor: an unlocked popover stretches to its 400px maximum and
* the wrapping grid follows it to eight columns. Two spare pixels so a frame
* border can never push the fifth cell onto its own row.
*/
const DESKTOP_WIDTH = CELL_ROW_WIDTH + CELL_GRID_PADDING * 2 + 2;
/** Colours are named in host appearance; profiles reuse both the palette and its labels. */
function colorLabel(t: TFunction, color: AgentProfileColor): string {
return t(`settings.host.appearance.color.options.${color}`);
}
function IconCell({
iconKey,
selected,
color,
onSelect,
}: {
iconKey: AgentProfileIconKey | null;
selected: boolean;
color: AgentProfileColor;
onSelect: (iconKey: AgentProfileIconKey | null) => void;
}) {
const handlePress = useCallback(() => onSelect(iconKey), [iconKey, onSelect]);
const accessibilityState = useMemo(() => ({ selected }), [selected]);
const cellStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.cell,
Boolean(hovered) && styles.cellHovered,
pressed && styles.cellPressed,
selected && styles.cellSelected,
],
[selected],
);
return (
<Pressable
onPress={handlePress}
style={cellStyle}
accessibilityRole="button"
accessibilityState={accessibilityState}
accessibilityLabel={iconKey ?? "default"}
testID={`agent-profile-icon-cell-${iconKey ?? "default"}`}
>
<AgentProfileGlyph
{...(iconKey ? { icon: iconKey } : {})}
color={color}
size={ICON_SIZE.lg}
/>
</Pressable>
);
}
function ColorCell({
color,
selected,
onSelect,
}: {
color: AgentProfileColor;
selected: boolean;
onSelect: (color: AgentProfileColor) => void;
}) {
const { t } = useTranslation();
const handlePress = useCallback(() => onSelect(color), [color, onSelect]);
const accessibilityState = useMemo(() => ({ selected }), [selected]);
const swatchStyle = useMemo(
() => [styles.swatch, color === "none" ? styles.swatchNone : identitySwatch(color)],
[color],
);
const cellStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [styles.colorCell, pressed && styles.cellPressed],
[],
);
return (
<Pressable
onPress={handlePress}
style={cellStyle}
accessibilityRole="button"
accessibilityState={accessibilityState}
accessibilityLabel={colorLabel(t, color)}
testID={`agent-profile-color-cell-${color}`}
>
<View style={swatchStyle}>
{selected ? (
<ThemedCheck
size={ICON_SIZE.sm}
uniProps={color === "none" ? foregroundMutedMapping : onIdentityMapping}
/>
) : null}
</View>
</Pressable>
);
}
/**
* Icon and colour in one popover rather than two fields: the pair is a single
* visual decision, and picking them apart means never seeing the result until
* both are closed. The grid redraws in the chosen colour as you switch colours,
* so the swatch row sits above it.
*/
export function AgentProfileAppearanceField({
label,
icon,
color,
onChange,
size = "md",
disabled = false,
testID,
triggerTestID,
}: {
label: string;
icon: string;
color: string;
onChange: (next: { icon: string; color: string }) => void;
size?: FieldControlSize;
disabled?: boolean;
testID?: string;
triggerTestID?: string;
}): ReactElement {
const { t } = useTranslation();
const anchorRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const selectedIcon = resolveAgentProfileIconKey(icon);
const selectedColor = resolveAgentProfileColor(color);
const handleTriggerPress = useCallback(() => {
if (disabled) {
return;
}
setOpen((current) => !current);
}, [disabled]);
// Colour keeps the popover open — you are still choosing. An icon is the last
// decision, so it commits and closes.
const handleColorSelect = useCallback(
(next: AgentProfileColor) => onChange({ icon, color: next === "none" ? "" : next }),
[icon, onChange],
);
const handleIconSelect = useCallback(
(next: AgentProfileIconKey | null) => {
onChange({ icon: next ?? "", color });
setOpen(false);
},
[color, onChange],
);
const triggerStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
size === "sm" ? styles.triggerSm : styles.triggerMd,
styles.triggerIconOnly,
resolveControlInteractionStyles(
{
controlRest: styles.controlRest,
controlHover: styles.controlHover,
controlActive: styles.controlActive,
controlDisabled: styles.controlDisabled,
},
{ hovered: Boolean(hovered), active: pressed || open, disabled },
),
],
[disabled, open, size],
);
const header = useMemo(() => ({ title: label }), [label]);
return (
<Field label={label} testID={testID}>
<View ref={anchorRef} collapsable={false}>
<Pressable
onPress={handleTriggerPress}
disabled={disabled}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={`${label} (${colorLabel(t, selectedColor)})`}
testID={triggerTestID}
>
<AgentProfileGlyph
{...(selectedIcon ? { icon: selectedIcon } : {})}
color={selectedColor}
size={ICON_SIZE.md}
/>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
</Pressable>
</View>
<Combobox
options={EMPTY_COMBOBOX_OPTIONS}
value=""
onSelect={noop}
open={open}
onOpenChange={setOpen}
anchorRef={anchorRef}
header={header}
desktopMinWidth={DESKTOP_WIDTH}
desktopLockWidth
>
<View style={styles.body} testID="agent-profile-appearance-picker">
<View style={styles.colorRow}>
{AGENT_PROFILE_COLORS.map((option) => (
<ColorCell
key={option}
color={option}
selected={option === selectedColor}
onSelect={handleColorSelect}
/>
))}
</View>
<View style={styles.grid}>
<IconCell
iconKey={null}
selected={selectedIcon === null}
color={selectedColor}
onSelect={handleIconSelect}
/>
{AGENT_PROFILE_ICON_KEYS.map((key) => (
<IconCell
key={key}
iconKey={key}
selected={key === selectedIcon}
color={selectedColor}
onSelect={handleIconSelect}
/>
))}
</View>
</View>
</Combobox>
</Field>
);
}
function noop() {}
/**
* One named rule per hue. Unistyles cannot resolve a colour computed at render,
* so the palette has to be spelled out as static styles.
*/
function identitySwatch(color: Exclude<AgentProfileColor, "none">) {
return styles[IDENTITY_SWATCH_STYLE_KEYS[color]];
}
const styles = StyleSheet.create((theme) => {
const geometry = createControlGeometry(theme);
return {
trigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
backgroundColor: theme.colors.surface2,
},
triggerSm: { ...geometry.fieldControlSm },
triggerMd: { ...geometry.fieldControlMd },
// The field geometry pads for a line of text. This trigger holds a glyph and
// a chevron, so it keeps the shared height and drops the text-sized inset.
triggerIconOnly: {
paddingHorizontal: theme.spacing[2],
},
controlRest: { ...geometry.controlRest },
controlHover: { ...geometry.controlHover },
controlActive: { ...geometry.controlActive },
controlDisabled: { ...geometry.controlDisabled },
body: {
padding: CELL_GRID_PADDING,
gap: theme.spacing[3],
},
colorRow: {
flexDirection: "row",
flexWrap: "wrap",
gap: CELL_GAP,
paddingBottom: theme.spacing[3],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
colorCell: {
width: COLOR_CELL_SIZE,
height: COLOR_CELL_SIZE,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
},
grid: {
flexDirection: "row",
flexWrap: "wrap",
gap: CELL_GAP,
},
cell: {
width: CELL_SIZE,
height: CELL_SIZE,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: "transparent",
},
cellHovered: {
backgroundColor: theme.colors.surface2,
},
cellPressed: {
opacity: 0.7,
},
cellSelected: {
backgroundColor: theme.colors.surface2,
borderColor: theme.colors.border,
},
swatch: {
width: SWATCH_SIZE,
height: SWATCH_SIZE,
borderRadius: SWATCH_SIZE / 2,
alignItems: "center",
justifyContent: "center",
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
swatchNone: {
backgroundColor: theme.colors.surface2,
},
swatchViolet: { backgroundColor: identityForeground("violet", theme.colorScheme) },
swatchSky: { backgroundColor: identityForeground("sky", theme.colorScheme) },
swatchEmerald: { backgroundColor: identityForeground("emerald", theme.colorScheme) },
swatchOrange: { backgroundColor: identityForeground("orange", theme.colorScheme) },
swatchPink: { backgroundColor: identityForeground("pink", theme.colorScheme) },
swatchIndigo: { backgroundColor: identityForeground("indigo", theme.colorScheme) },
swatchTeal: { backgroundColor: identityForeground("teal", theme.colorScheme) },
swatchRed: { backgroundColor: identityForeground("red", theme.colorScheme) },
swatchAmber: { backgroundColor: identityForeground("amber", theme.colorScheme) },
swatchBlue: { backgroundColor: identityForeground("blue", theme.colorScheme) },
};
});
const IDENTITY_SWATCH_STYLE_KEYS = {
violet: "swatchViolet",
sky: "swatchSky",
emerald: "swatchEmerald",
orange: "swatchOrange",
pink: "swatchPink",
indigo: "swatchIndigo",
teal: "swatchTeal",
red: "swatchRed",
amber: "swatchAmber",
blue: "swatchBlue",
} as const;
@@ -0,0 +1,549 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import type { AgentFeature } from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { type FieldControlSize } from "@/components/ui/control-geometry";
import { Field, FormTextInput } from "@/components/ui/form-field";
import {
SelectField,
type SelectFieldDisplay,
type SelectFieldOption,
} from "@/components/ui/select-field";
import { Switch } from "@/components/ui/switch";
import { useIsCompactFormFactor } from "@/constants/layout";
import { toErrorMessage } from "@/utils/error-messages";
import { AgentProfileAppearanceField } from "./agent-profile-appearance-field";
import type {
AgentProfileFormModel,
AgentProfileFormOption,
AgentProfileValue,
} from "../internal/profile-form-model";
import {
useAgentProfileFormCatalog,
useAgentProfileFormFeatures,
} from "../internal/use-profile-form-inputs";
import {
useAgentProfileFormModel,
useAgentProfileFormState,
} from "../internal/use-profile-form-model";
export interface AgentProfileEditModalProps {
serverId: string;
visible: boolean;
mode: "create" | "edit";
profile?: AgentProfile;
onClose: () => void;
onSave: (value: AgentProfileValue) => Promise<void>;
}
/**
* Only reachable before the catalog lands. Every select is seeded to a concrete
* id as soon as one exists, and none of them offer an "unset" row.
*/
const UNSET_VALUE = "";
function openKey(props: AgentProfileEditModalProps): string {
return props.mode === "edit" ? `edit:${props.profile?.id ?? ""}` : "create";
}
/**
* Create and edit never share a mounted instance: the body is keyed on mode plus
* profile id, so every open constructs a form model seeded from that record.
*/
export function AgentProfileEditModal(props: AgentProfileEditModalProps): ReactElement | null {
const [renderedProps, setRenderedProps] = useState<AgentProfileEditModalProps | null>(() =>
props.visible ? props : null,
);
const [sheetVisible, setSheetVisible] = useState(props.visible);
const livePropsRef = useRef(props);
const closeRequestedRef = useRef(false);
livePropsRef.current = props;
useEffect(() => {
if (props.visible) {
if (closeRequestedRef.current) {
return;
}
setRenderedProps(props);
setSheetVisible(true);
return;
}
if (renderedProps) {
setSheetVisible(false);
}
}, [props, renderedProps]);
const requestClose = useCallback(() => {
closeRequestedRef.current = true;
setSheetVisible(false);
}, []);
const handleDismiss = useCallback(() => {
const dismissedProps = livePropsRef.current;
closeRequestedRef.current = false;
setRenderedProps(null);
setSheetVisible(false);
if (dismissedProps.visible) {
dismissedProps.onClose();
}
}, []);
if (!renderedProps) {
return null;
}
return (
<OpenAgentProfileEditModal
key={openKey(renderedProps)}
{...renderedProps}
visible={sheetVisible}
onClose={requestClose}
onDismiss={handleDismiss}
/>
);
}
function toSelectOptions(options: AgentProfileFormOption[]): SelectFieldOption<string>[] {
return options.map((option) => ({
id: option.id,
value: option.value,
label: option.label,
...(option.description ? { description: option.description } : {}),
testID: option.testID,
}));
}
function OpenAgentProfileEditModal({
serverId,
visible,
mode,
profile,
onClose,
onDismiss,
onSave,
}: AgentProfileEditModalProps & { onDismiss: () => void }): ReactElement {
const { t } = useTranslation();
const controlSize: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm";
const snapshot = useMemo(() => ({ mode, ...(profile ? { profile } : {}) }), [mode, profile]);
const model = useAgentProfileFormModel(snapshot);
const state = useAgentProfileFormState(model);
useAgentProfileFormCatalog({ serverId, model });
useAgentProfileFormFeatures({ serverId, model, state });
const sheetHeader = useMemo<SheetHeader>(
() => ({
title:
mode === "edit"
? t("settings.host.agentProfiles.editProfileTitle")
: t("settings.host.agentProfiles.addProfileTitle"),
}),
[mode, t],
);
const providerOptions = useMemo(
() => toSelectOptions(state.providerOptions),
[state.providerOptions],
);
const modelOptions = useMemo(() => toSelectOptions(state.modelOptions), [state.modelOptions]);
const modeOptions = useMemo(() => toSelectOptions(state.modeOptions), [state.modeOptions]);
const thinkingOptions = useMemo(
() => toSelectOptions(state.thinkingOptions),
[state.thinkingOptions],
);
const handleAppearanceChange = useCallback(
(value: { icon: string; color: string }) => model.setAppearance(value),
[model],
);
const handleProviderChange = useCallback(
(value: string, display: SelectFieldDisplay) => model.setProvider(value, display),
[model],
);
const handleModelChange = useCallback(
(value: string, display: SelectFieldDisplay) => model.setModel(value, value ? display : null),
[model],
);
const handleModeChange = useCallback(
(value: string, display: SelectFieldDisplay) => model.setMode(value, value ? display : null),
[model],
);
const handleThinkingChange = useCallback(
(value: string, display: SelectFieldDisplay) =>
model.setThinking(value, value ? display : null),
[model],
);
const handleSave = useCallback(async () => {
const value = model.getState().submitValue;
if (!value) {
return;
}
model.setSubmitError(null);
model.setSubmitting(true);
try {
await onSave(value);
onClose();
} catch (error) {
model.setSubmitError(toErrorMessage(error));
} finally {
model.setSubmitting(false);
}
}, [model, onClose, onSave]);
const handleSavePress = useCallback(() => {
void handleSave();
}, [handleSave]);
const handleCancel = useCallback(() => {
if (state.isSubmitting) {
return;
}
onClose();
}, [onClose, state.isSubmitting]);
return (
<AdaptiveModalSheet
visible={visible}
header={sheetHeader}
onClose={handleCancel}
onDismiss={onDismiss}
desktopMaxWidth={520}
testID="agent-profile-edit-modal"
>
<View style={styles.body}>
<View style={styles.nameRow}>
<View style={styles.iconField}>
<AgentProfileAppearanceField
label={t("settings.host.agentProfiles.iconLabel")}
icon={state.icon}
color={state.color}
onChange={handleAppearanceChange}
disabled={state.isSubmitting}
size={controlSize}
testID="agent-profile-icon-field"
triggerTestID="agent-profile-icon-trigger"
/>
</View>
<View style={styles.nameField}>
<Field
label={t("settings.host.agentProfiles.nameLabel")}
testID="agent-profile-name-field"
>
<FormTextInput
initialValue={profile?.name ?? ""}
onChangeText={model.setName}
placeholder={t("settings.host.agentProfiles.namePlaceholder")}
autoCapitalize="none"
autoCorrect={false}
editable={!state.isSubmitting}
size={controlSize}
accessibilityLabel={t("settings.host.agentProfiles.nameLabel")}
testID="agent-profile-name-input"
/>
</Field>
</View>
</View>
<SelectField
label={t("settings.host.agentProfiles.providerLabel")}
value={state.provider || null}
selectedDisplay={state.providerDisplay}
options={providerOptions}
onChange={handleProviderChange}
placeholder={t("settings.host.agentProfiles.providerPlaceholder")}
emptyText={t("settings.host.agentProfiles.noProviders")}
loading={state.catalogResolution !== "complete"}
disabled={state.isSubmitting}
searchable={providerOptions.length > 6}
title={t("settings.host.agentProfiles.providerLabel")}
size={controlSize}
testID="agent-profile-provider-field"
triggerTestID="agent-profile-provider-trigger"
/>
{state.disclosure.showModelField ? (
<SelectField
label={t("settings.host.agentProfiles.modelLabel")}
value={state.modelId || UNSET_VALUE}
selectedDisplay={state.modelDisplay}
options={modelOptions}
onChange={handleModelChange}
placeholder={t("settings.host.agentProfiles.modelLabel")}
emptyText={t("settings.host.agentProfiles.noModels")}
disabled={state.isSubmitting}
searchable={modelOptions.length > 6}
title={t("settings.host.agentProfiles.modelLabel")}
size={controlSize}
testID="agent-profile-model-field"
triggerTestID="agent-profile-model-trigger"
/>
) : null}
{state.disclosure.showModeField ? (
<SelectField
label={t("settings.host.agentProfiles.modeLabel")}
value={state.modeId || UNSET_VALUE}
selectedDisplay={state.modeDisplay}
options={modeOptions}
onChange={handleModeChange}
placeholder={t("settings.host.agentProfiles.modeLabel")}
emptyText={t("settings.host.agentProfiles.noModes")}
disabled={state.isSubmitting}
searchable={modeOptions.length > 6}
title={t("settings.host.agentProfiles.modeLabel")}
size={controlSize}
testID="agent-profile-mode-field"
triggerTestID="agent-profile-mode-trigger"
/>
) : null}
{state.disclosure.showThinkingField ? (
<SelectField
label={t("settings.host.agentProfiles.thinkingLabel")}
value={state.thinkingOptionId || UNSET_VALUE}
selectedDisplay={state.thinkingDisplay}
options={thinkingOptions}
onChange={handleThinkingChange}
placeholder={t("settings.host.agentProfiles.thinkingLabel")}
emptyText={t("settings.host.agentProfiles.noThinkingOptions")}
disabled={state.isSubmitting}
searchable={thinkingOptions.length > 6}
title={t("settings.host.agentProfiles.thinkingLabel")}
size={controlSize}
testID="agent-profile-thinking-field"
triggerTestID="agent-profile-thinking-trigger"
/>
) : null}
{state.disclosure.showFeaturesField ? (
<Field
label={t("settings.host.agentProfiles.featuresLabel")}
testID="agent-profile-features-field"
>
<View style={styles.featureCard} testID="agent-profile-features-card">
{state.features.map((feature, index) => (
<AgentProfileFeatureRow
key={feature.id}
feature={feature}
isFirst={index === 0}
disabled={state.isSubmitting}
size={controlSize}
model={model}
/>
))}
</View>
</Field>
) : null}
<Field
label={t("settings.host.agentProfiles.notesLabel")}
hint={t("settings.host.agentProfiles.notesHint")}
testID="agent-profile-notes-field"
>
<FormTextInput
initialValue={profile?.notes ?? ""}
onChangeText={model.setNotes}
placeholder={t("settings.host.agentProfiles.notesPlaceholder")}
multiline
numberOfLines={4}
style={styles.notesInput}
editable={!state.isSubmitting}
size={controlSize}
accessibilityLabel={t("settings.host.agentProfiles.notesLabel")}
testID="agent-profile-notes-input"
/>
</Field>
{state.submitError ? (
<Text style={styles.submitError} testID="agent-profile-submit-error">
{state.submitError}
</Text>
) : null}
<View style={styles.actions}>
<Button
variant="secondary"
style={styles.actionButton}
onPress={handleCancel}
disabled={state.isSubmitting}
testID="agent-profile-cancel-button"
>
{t("common.actions.cancel")}
</Button>
<Button
variant="default"
style={styles.actionButton}
onPress={handleSavePress}
disabled={!state.canSubmit}
testID="agent-profile-save-button"
>
{state.isSubmitting
? t("settings.host.agentProfiles.saving")
: t("settings.host.agentProfiles.save")}
</Button>
</View>
</View>
</AdaptiveModalSheet>
);
}
function AgentProfileFeatureRow({
feature,
isFirst,
disabled,
size,
model,
}: {
feature: AgentFeature;
isFirst: boolean;
disabled: boolean;
size: FieldControlSize;
model: AgentProfileFormModel;
}): ReactElement {
const rowStyle = useMemo(
() => [styles.featureRow, isFirst ? null : styles.featureRowBorder],
[isFirst],
);
const handleToggle = useCallback(
(value: boolean) => model.setFeatureValue(feature.id, value),
[feature.id, model],
);
const handleSelect = useCallback(
(value: string) => model.setFeatureValue(feature.id, value),
[feature.id, model],
);
const selectOptions = useMemo<SelectFieldOption<string>[]>(
() =>
feature.type === "select"
? feature.options.map((option) => ({
id: option.id,
value: option.id,
label: option.label,
...(option.description ? { description: option.description } : {}),
testID: `agent-profile-feature-option-${feature.id}-${option.id}`,
}))
: [],
[feature],
);
const selectedDisplay = useMemo<SelectFieldDisplay | null>(() => {
if (feature.type !== "select" || !feature.value) {
return null;
}
const option = feature.options.find((candidate) => candidate.id === feature.value);
return { label: option?.label ?? feature.value };
}, [feature]);
if (feature.type === "select") {
return (
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
<SelectField
label={feature.label}
value={feature.value}
selectedDisplay={selectedDisplay}
options={selectOptions}
onChange={handleSelect}
placeholder={feature.label}
emptyText={feature.label}
disabled={disabled}
{...(feature.description ? { hint: feature.description } : {})}
title={feature.label}
size={size}
testID={`agent-profile-feature-field-${feature.id}`}
triggerTestID={`agent-profile-feature-trigger-${feature.id}`}
/>
</View>
);
}
return (
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
<View style={styles.featureMeta}>
<Text style={styles.featureLabel} numberOfLines={1}>
{feature.label}
</Text>
{feature.description ? (
<Text style={styles.featureDescription} numberOfLines={2}>
{feature.description}
</Text>
) : null}
</View>
<Switch
value={feature.value === true}
onValueChange={handleToggle}
disabled={disabled}
accessibilityLabel={feature.label}
testID={`agent-profile-feature-switch-${feature.id}`}
/>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
body: {
gap: theme.spacing[4],
paddingBottom: theme.spacing[2],
},
nameRow: {
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[2],
},
// No width: the trigger is a glyph and a chevron, so it sizes to them.
iconField: {
flexShrink: 0,
},
nameField: {
flex: 1,
},
featureCard: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
paddingHorizontal: theme.spacing[3],
},
featureRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[3],
paddingVertical: theme.spacing[3],
},
featureRowBorder: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
featureMeta: {
flex: 1,
gap: theme.spacing[1],
},
featureLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
featureDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
notesInput: {
minHeight: 88,
textAlignVertical: "top",
},
submitError: {
color: theme.colors.statusDanger,
fontSize: theme.fontSize.sm,
},
actions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginTop: theme.spacing[2],
},
actionButton: {
flex: 1,
},
}));
@@ -0,0 +1,181 @@
import { useCallback, useMemo, type ReactElement } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, FileText, Pencil, Trash2 } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { Button } from "@/components/ui/button";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { AgentProfileGlyph } from "../internal/agent-profile-glyph";
import { buildAgentProfileTags } from "../internal/profile-summary";
const ThemedArrowUp = withUnistyles(ArrowUp);
const ThemedArrowDown = withUnistyles(ArrowDown);
const ThemedPencil = withUnistyles(Pencil);
const ThemedTrash2 = withUnistyles(Trash2);
const ThemedFileText = withUnistyles(FileText);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
const moveUpIcon = <ThemedArrowUp size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
const moveDownIcon = <ThemedArrowDown size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
const editIcon = <ThemedPencil size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
const removeIcon = <ThemedTrash2 size={ICON_SIZE.sm} uniProps={destructiveColorMapping} />;
export interface AgentProfileRowProps {
profile: AgentProfile;
entries: readonly ProviderSnapshotEntry[] | undefined;
isFirst: boolean;
isLast: boolean;
onEdit: (id: string) => void;
onRemove: (id: string) => void;
onMoveUp: (id: string) => void;
onMoveDown: (id: string) => void;
}
export function AgentProfileRow({
profile,
entries,
isFirst,
isLast,
onEdit,
onRemove,
onMoveUp,
onMoveDown,
}: AgentProfileRowProps): ReactElement {
const { t } = useTranslation();
const handleEdit = useCallback(() => onEdit(profile.id), [onEdit, profile.id]);
const handleRemove = useCallback(() => onRemove(profile.id), [onRemove, profile.id]);
const handleMoveUp = useCallback(() => onMoveUp(profile.id), [onMoveUp, profile.id]);
const handleMoveDown = useCallback(() => onMoveDown(profile.id), [onMoveDown, profile.id]);
const formatFeatureCount = useCallback(
(count: number) =>
count === 1
? t("settings.host.agentProfiles.featureCountOne", { count })
: t("settings.host.agentProfiles.featureCount", { count }),
[t],
);
const tags = useMemo(
() => buildAgentProfileTags({ profile, entries, formatFeatureCount }),
[entries, formatFeatureCount, profile],
);
const summary = useMemo(() => tags.map((tag) => tag.label).join(" · "), [tags]);
const rowStyle = useMemo(
() => [settingsStyles.row, isFirst ? null : settingsStyles.rowBorder, styles.row],
[isFirst],
);
return (
<View style={rowStyle} testID={`agent-profile-row-${profile.id}`}>
<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>
{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>
<View style={styles.rowActions}>
<Button
variant="ghost"
size="sm"
leftIcon={moveUpIcon}
onPress={handleMoveUp}
disabled={isFirst}
accessibilityLabel={t("settings.host.agentProfiles.moveUp")}
testID={`agent-profile-move-up-${profile.id}`}
/>
<Button
variant="ghost"
size="sm"
leftIcon={moveDownIcon}
onPress={handleMoveDown}
disabled={isLast}
accessibilityLabel={t("settings.host.agentProfiles.moveDown")}
testID={`agent-profile-move-down-${profile.id}`}
/>
<Button
variant="ghost"
size="sm"
leftIcon={editIcon}
onPress={handleEdit}
accessibilityLabel={t("settings.host.agentProfiles.editProfile")}
testID={`agent-profile-edit-${profile.id}`}
/>
<Button
variant="ghost"
size="sm"
leftIcon={removeIcon}
onPress={handleRemove}
accessibilityLabel={t("settings.host.agentProfiles.remove")}
testID={`agent-profile-remove-${profile.id}`}
/>
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
gap: theme.spacing[2],
minHeight: 56,
alignItems: "center",
paddingVertical: theme.spacing[3],
},
iconWrapper: {
width: theme.iconSize.md,
alignSelf: "flex-start",
alignItems: "center",
justifyContent: "center",
marginTop: theme.spacing[1],
},
titleLine: {
flexDirection: "row",
alignItems: "baseline",
gap: theme.spacing[2],
minWidth: 0,
},
summary: {
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
notes: {
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[1],
marginTop: theme.spacing[1],
},
notesText: {
flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
rowActions: {
flexDirection: "row",
alignItems: "center",
},
}));
@@ -0,0 +1,219 @@
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { Alert, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { Button } from "@/components/ui/button";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useAgentProfiles } from "../internal/use-agent-profiles";
import type { AgentProfileValue } from "../internal/profile-form-model";
import { AgentProfileEditModal } from "./agent-profile-edit-modal";
import { AgentProfileRow } from "./agent-profile-row";
const ThemedPlus = withUnistyles(Plus);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const addIcon = <ThemedPlus size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
function generateAgentProfileId(): string {
return `agent_profile_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
}
interface EditTarget {
mode: "create" | "edit";
profile?: AgentProfile;
}
export function AgentProfilesSection({ serverId }: { serverId: string }): ReactElement {
const { t } = useTranslation();
const isConnected = useHostRuntimeIsConnected(serverId);
const { profiles, isSupported, saveProfiles } = useAgentProfiles(serverId);
const { entries } = useProvidersSnapshot(serverId, { cwd: null });
const [editTarget, setEditTarget] = useState<EditTarget | null>(null);
const handleAddOpen = useCallback(() => setEditTarget({ mode: "create" }), []);
const handleEditClose = useCallback(() => setEditTarget(null), []);
const handleEditOpen = useCallback(
(id: string) => {
const profile = profiles?.find((entry) => entry.id === id);
if (!profile) {
return;
}
setEditTarget({ mode: "edit", profile });
},
[profiles],
);
const handleSave = useCallback(
async (value: AgentProfileValue) => {
const current = profiles ?? [];
const editing = editTarget?.mode === "edit" ? editTarget.profile : undefined;
// The edited profile is replaced, not merged: `value` omits the fields the
// user cleared, so spreading it over the stored record would silently keep
// the old model, mode, thinking option or notes.
const next: AgentProfile[] = editing
? current.map((entry) => (entry.id === editing.id ? { id: entry.id, ...value } : entry))
: [...current, { id: generateAgentProfileId(), ...value }];
await saveProfiles(next);
},
[editTarget, profiles, saveProfiles],
);
const reorder = useCallback(
async (id: string, offset: -1 | 1) => {
if (!profiles) {
return;
}
const index = profiles.findIndex((entry) => entry.id === id);
const target = index + offset;
if (index < 0 || target < 0 || target >= profiles.length) {
return;
}
const next = [...profiles];
const [item] = next.splice(index, 1);
next.splice(target, 0, item);
try {
await saveProfiles(next);
} catch (error) {
Alert.alert(
t("common.errors.unableToSave"),
error instanceof Error ? error.message : String(error),
);
}
},
[profiles, saveProfiles, t],
);
const handleMoveUp = useCallback((id: string) => void reorder(id, -1), [reorder]);
const handleMoveDown = useCallback((id: string) => void reorder(id, 1), [reorder]);
const handleRemove = useCallback(
(id: string) => {
const profile = profiles?.find((entry) => entry.id === id);
if (!profile) {
return;
}
void confirmDialog({
title: t("settings.host.agentProfiles.removeConfirmTitle"),
message: t("settings.host.agentProfiles.removeConfirmMessage", { name: profile.name }),
confirmLabel: t("settings.host.agentProfiles.remove"),
cancelLabel: t("common.actions.cancel"),
destructive: true,
}).then(async (confirmed) => {
if (!confirmed || !profiles) {
return;
}
try {
await saveProfiles(profiles.filter((entry) => entry.id !== id));
} catch (error) {
Alert.alert(
t("common.errors.unableToSave"),
error instanceof Error ? error.message : String(error),
);
}
return;
});
},
[profiles, saveProfiles, t],
);
const addButton = useMemo(
() => (
<Button
variant="ghost"
size="sm"
leftIcon={addIcon}
onPress={handleAddOpen}
disabled={!profiles}
accessibilityLabel={t("settings.host.agentProfiles.addProfileTitle")}
testID="agent-profiles-add-button"
/>
),
[handleAddOpen, profiles, t],
);
if (!isConnected || !isSupported) {
return (
<SettingsSection
title={t("settings.host.agentProfiles.sectionTitle")}
testID="agent-profiles-section"
>
<View style={settingsStyles.card} testID="agent-profiles-unavailable">
<View style={styles.emptyCard}>
<Text style={styles.emptyText}>
{isConnected
? t("settings.host.agentProfiles.unsupported")
: t("settings.host.agentProfiles.unavailable")}
</Text>
</View>
</View>
</SettingsSection>
);
}
return (
<>
<SettingsSection
title={t("settings.host.agentProfiles.sectionTitle")}
trailing={addButton}
testID="agent-profiles-section"
>
<View style={settingsStyles.card} testID="agent-profiles-card">
{profiles && profiles.length > 0 ? (
profiles.map((profile, index) => (
<AgentProfileRow
key={profile.id}
profile={profile}
entries={entries}
isFirst={index === 0}
isLast={index === profiles.length - 1}
onEdit={handleEditOpen}
onRemove={handleRemove}
onMoveUp={handleMoveUp}
onMoveDown={handleMoveDown}
/>
))
) : (
<View style={styles.emptyCard}>
<Text style={styles.emptyText} testID="agent-profiles-empty">
{t("settings.host.agentProfiles.emptyState")}
</Text>
<Button size="sm" leftIcon={addIcon} onPress={handleAddOpen} disabled={!profiles}>
{t("settings.host.agentProfiles.newProfile")}
</Button>
</View>
)}
</View>
</SettingsSection>
<AgentProfileEditModal
serverId={serverId}
visible={editTarget !== null}
mode={editTarget?.mode ?? "create"}
{...(editTarget?.profile ? { profile: editTarget.profile } : {})}
onClose={handleEditClose}
onSave={handleSave}
/>
</>
);
}
const styles = StyleSheet.create((theme) => ({
emptyCard: {
paddingVertical: theme.spacing[6],
paddingHorizontal: theme.spacing[4],
alignItems: "center",
gap: theme.spacing[3],
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
textAlign: "center",
},
}));
@@ -143,6 +143,30 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
}
describe("layoutStream", () => {
it.each(["web", "android"] as const)(
"marks only the active live-head assistant block as streaming on %s",
(platform) => {
const completed = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
const live = assistantMessage("turn:block:1", 3, { groupId: "turn", index: 1 });
const active = layoutFor({
platform,
isTurnActive: true,
tail: [userMessage("u1", 1), completed],
head: [live],
});
const complete = layoutFor({
platform,
isTurnActive: false,
tail: [userMessage("u1", 1), completed],
head: [live],
});
expect(findLayoutItem(active, completed.id).phase).toBe("complete");
expect(findLayoutItem(active, live.id).phase).toBe("streaming");
expect(findLayoutItem(complete, live.id).phase).toBe("complete");
},
);
it.each(["web", "android"] as const)(
"keeps split assistant block spacing identical to unsplit history on %s",
(platform) => {
+5
View File
@@ -26,6 +26,7 @@ export interface StreamLayoutItem {
isLastInUserGroup: boolean;
isLastInToolSequence: boolean;
frameOrder: StreamFrameChildOrder;
phase: "streaming" | "complete";
}
export interface StreamLayout {
@@ -53,6 +54,7 @@ interface LayoutSegmentInput {
boundaryBelowItem: StreamItem | null;
boundaryAboveItems: StreamItem[] | null;
boundaryAboveIndex: number | null;
phase: "streaming" | "complete";
}
interface AssistantFooterSource {
@@ -273,6 +275,7 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
isLastInUserGroup: item.kind === "user_message" && belowItem?.kind !== "user_message",
isLastInToolSequence: isToolSequenceItem(item) && !isToolSequenceItem(belowItem),
frameOrder: input.frameOrder,
phase: input.phase,
};
});
}
@@ -324,6 +327,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
boundaryBelowItem: liveHeadBoundaryItem,
boundaryAboveItems: null,
boundaryAboveIndex: null,
phase: "complete",
});
byKey.set(historyCacheKey, history);
}
@@ -342,6 +346,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
boundaryBelowItem: null,
boundaryAboveItems: input.history,
boundaryAboveIndex: historyBoundaryIndex,
phase: input.isTurnActive ? "streaming" : "complete",
});
return {
@@ -206,6 +206,78 @@ describe("createWebStreamStrategy", () => {
expect(renderLiveHeadRow).toHaveBeenCalledTimes(2);
});
it("keeps a row mounted when it moves from the live head into mounted history", () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
const viewportRef = React.createRef<StreamViewportHandle>();
const item = userMessage(1);
const mounted = vi.fn();
const unmounted = vi.fn();
function StatefulRow() {
React.useEffect(() => {
mounted();
return unmounted;
}, []);
return <div>{item.id}</div>;
}
const renderers: StreamSegmentRenderers = {
...createRenderers(vi.fn()),
renderHistoryMountedRow: () => <StatefulRow />,
renderLiveHeadRow: () => <StatefulRow />,
};
const renderInput: Omit<StreamRenderInput, "segments" | "boundary"> = {
agentId: "agent",
renderers,
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn().mockReturnValue(true),
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: { historyVirtualized: [], historyMounted: [], liveHead: [item] },
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: false,
hasLiveHead: true,
},
}),
);
});
expect(mounted).toHaveBeenCalledTimes(1);
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: { historyVirtualized: [], historyMounted: [item], liveHead: [] },
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
}),
);
});
expect(mounted).toHaveBeenCalledTimes(1);
expect(unmounted).not.toHaveBeenCalled();
});
it("reports the live-head row as the reading position", () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
const viewportRef = React.createRef<StreamViewportHandle>();
@@ -1068,6 +1068,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
</div>
));
}, [liveHeadRowRevision, renderLiveHeadRow, segments.liveHead]);
const mountedRows = useMemo(
() => [...mountedHistoryRows, ...liveHeadRows],
[liveHeadRows, mountedHistoryRows],
);
const liveAuxiliary = useMemo(() => {
return renderLiveAuxiliary();
}, [renderLiveAuxiliary]);
@@ -1119,8 +1123,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
})}
</div>
) : null}
{mountedHistoryRows}
{liveHeadRows}
{mountedRows}
{liveAuxiliary}
{shouldRenderEmpty ? listEmptyComponent : null}
</div>
+2 -1
View File
@@ -653,6 +653,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
serverId={resolvedServerId}
client={client}
spacing={layoutItem.assistantSpacing}
phase={layoutItem.phase}
/>
</AssistantFileLinkResolverProvider>
);
@@ -798,7 +799,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
case "todo_list":
return <TodoListCard items={item.items} />;
return <TodoListCard items={item.items} activity={item.activity} />;
case "compaction":
return (
@@ -0,0 +1,27 @@
import { execFileSync } from "node:child_process";
import path from "node:path";
import { describe, expect, it } from "vitest";
describe("Android AsyncStorage configuration", () => {
it("writes a 10 MiB database limit into Gradle properties", () => {
const appDirectory = path.resolve(import.meta.dirname, "..");
const output = execFileSync(
process.execPath,
[
"-e",
`
const plugin = require("./plugins/with-android-async-storage-size");
const config = plugin({ name: "Paseo", slug: "paseo" }, 10);
const mod = config.mods.android.gradleProperties;
mod({ ...config, modResults: [], modRequest: {}, modRawConfig: config })
.then((result) => process.stdout.write(JSON.stringify(result.modResults)));
`,
],
{ cwd: appDirectory, encoding: "utf8" },
);
expect(JSON.parse(output)).toEqual([
{ type: "property", key: "AsyncStorage_db_size_in_MB", value: "10" },
]);
});
});
+28 -6
View File
@@ -72,6 +72,7 @@ import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-rout
import { ThemedStack } from "@/navigation/themed-stack";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { AgentNavigationListener } from "@/desktop/agent-navigation";
import { legacyFavoriteProfileMigration } from "@/agent-profiles/migration";
import { listenToDesktopEvent } from "@/desktop/electron/events";
import { updateDesktopWindowControls } from "@/desktop/electron/window";
import { getDesktopHost } from "@/desktop/host";
@@ -101,13 +102,15 @@ import {
useHostRegistryLoaded,
useHostMutations,
useHostRuntimeClient,
useHostRuntimeIsConnected,
useHosts,
} from "@/runtime/host-runtime";
import { getDaemonStartService } from "@/runtime/daemon-start-service";
import { applyAppearance } from "@/screens/settings/appearance/apply-appearance";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { flushDraftPersistStorage } from "@/stores/draft-store";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
import { getNextThemePreference, THEME_TO_UNISTYLES } from "@/styles/theme";
import { useSessionStore } from "@/stores/session-store";
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
import type { HostProfile } from "@/types/host-connection";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
@@ -266,11 +269,33 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
return (
<SessionProvider key={daemon.serverId} serverId={daemon.serverId} client={client}>
{null}
<LegacyFavoriteProfileMigrationBootstrap serverId={daemon.serverId} client={client} />
</SessionProvider>
);
}
function LegacyFavoriteProfileMigrationBootstrap({
serverId,
client,
}: {
serverId: string;
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
}) {
const serverInfo = useSessionStore((state) => state.sessions[serverId]?.serverInfo ?? null);
const isConnected = useHostRuntimeIsConnected(serverId);
useEffect(() => {
if (!serverInfo || !isConnected) {
return;
}
void legacyFavoriteProfileMigration.migrateHost(serverId, client).catch((error) => {
console.warn("[AgentProfiles] Failed to migrate legacy favourites", error);
});
}, [client, isConnected, serverId, serverInfo]);
return null;
}
function HostSessionManager() {
const hosts = useHosts();
@@ -424,7 +449,6 @@ interface AppContainerProps {
chromeEnabled?: boolean;
}
const THEME_CYCLE_ORDER: ThemeName[] = ["dark", "zinc", "midnight", "claude", "ghostty", "light"];
const WINDOW_SIDEBAR_TOGGLE_HORIZONTAL_PADDING = 12;
function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppContainerProps) {
@@ -444,9 +468,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const { width: viewportWidth } = useWindowDimensions();
const cycleTheme = useCallback(() => {
const currentIndex = THEME_CYCLE_ORDER.indexOf(settings.theme as ThemeName);
const nextIndex = (currentIndex + 1) % THEME_CYCLE_ORDER.length;
void updateSettings({ theme: THEME_CYCLE_ORDER[nextIndex] });
void updateSettings({ theme: getNextThemePreference(settings.theme) });
}, [settings.theme, updateSettings]);
const isCompactLayout = useIsCompactFormFactor();
+9 -31
View File
@@ -1,16 +1,9 @@
import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react";
import {
Platform,
Pressable,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from "react-native";
import { useMemo, type CSSProperties, type MouseEvent, type ReactNode } from "react";
import { Platform, Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { isNative, isWeb } from "@/constants/platform";
import { MarkdownTextSpan } from "@/components/markdown-text";
import { MarkdownLinkText } from "@/components/markdown/link-text";
import { AssistantLinkPressProvider, type AssistantLinkPress } from "./link-press-context";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -39,7 +32,6 @@ export function AssistantMarkdownLink({
monoSurface,
children,
}: AssistantMarkdownLinkProps) {
const [hovered, setHovered] = useState(false);
const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source);
const { configRef } = useAssistantFileLinkResolverContext();
const workspaceRoot = configRef.current.workspaceRoot;
@@ -55,15 +47,6 @@ export function AssistantMarkdownLink({
event.stopPropagation();
onAuxPress();
});
const handleHoverIn = useStableEvent(() => {
setHovered(true);
onHoverIn();
});
const handleHoverOut = useStableEvent(() => setHovered(false));
const hoveredTextStyle = useMemo<StyleProp<TextStyle>>(
() => [style, hovered && { textDecorationLine: "underline" as const }],
[style, hovered],
);
const linkPress = useMemo<AssistantLinkPress>(
() => ({ onPress, accessibilityRole: "link" }),
[onPress],
@@ -114,19 +97,14 @@ export function AssistantMarkdownLink({
onAuxClickCapture={preventAnchorNavigation}
style={LINK_ANCHOR_STYLE}
>
<Pressable
accessibilityRole="link"
<MarkdownLinkText
dataSet={monoSurface ? MARKDOWN_CODE_LINK_DATASET : undefined}
style={style}
onPress={onPress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
onHoverIn={onHoverIn}
>
<Text
dataSet={monoSurface ? MARKDOWN_CODE_LINK_DATASET : undefined}
style={hoveredTextStyle}
>
{children}
</Text>
</Pressable>
{children}
</MarkdownLinkText>
</a>
);
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import type { SidebarGroupMode } from "@/stores/sidebar-view-store";
import type { CommandCenterIconProps } from "./contributions";
import { buildGroupingContribution, type GroupingCommandCenterSource } from "./root-contributions";
function ProjectIcon(_props: CommandCenterIconProps) {
return null;
}
function StatusIcon(_props: CommandCenterIconProps) {
return null;
}
function source(groupMode: SidebarGroupMode): {
value: GroupingCommandCenterSource;
applied: SidebarGroupMode[];
} {
const applied: SidebarGroupMode[] = [];
return {
value: {
groupMode,
labels: {
section: "Actions",
groupByProject: "Group by project",
groupByStatus: "Group by status",
},
icons: { project: ProjectIcon, status: StatusIcon },
setGroupMode: (mode) => applied.push(mode),
},
applied,
};
}
describe("grouping command center contribution", () => {
it("offers status while grouped by project", () => {
const fixture = source("project");
const contribution = buildGroupingContribution(fixture.value);
expect(contribution.presentation).toMatchObject({
title: "Group by status",
icon: StatusIcon,
});
contribution.run();
expect(fixture.applied).toEqual(["status"]);
});
it("offers project while grouped by status", () => {
const fixture = source("status");
const contribution = buildGroupingContribution(fixture.value);
expect(contribution.presentation).toMatchObject({
title: "Group by project",
icon: ProjectIcon,
});
contribution.run();
expect(fixture.applied).toEqual(["project"]);
});
it("keeps a stable id across both modes so the registry tiebreak never moves", () => {
expect(buildGroupingContribution(source("project").value).id).toBe(
buildGroupingContribution(source("status").value).id,
);
});
it("stays out of the default empty-query list", () => {
for (const mode of ["project", "status"] as const) {
const contribution = buildGroupingContribution(source(mode).value);
expect(contribution.visibility).toBe("query");
expect(contribution.group).toBe("actions");
// 6 is keyboard-shortcuts and 7 belongs to the workspace actions in #3013.
expect(contribution.rank).toBe(8);
}
});
});
@@ -0,0 +1,39 @@
import type { SidebarGroupMode } from "@/stores/sidebar-view-store";
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
export interface GroupingCommandCenterSource {
groupMode: SidebarGroupMode;
labels: {
section: string;
groupByProject: string;
groupByStatus: string;
};
icons: {
project?: CommandCenterIcon;
status?: CommandCenterIcon;
};
setGroupMode(mode: SidebarGroupMode): void;
}
// One entry that always names the mode you are not in, so it can never read as a no-op.
export function buildGroupingContribution(
source: GroupingCommandCenterSource,
): CommandCenterContribution {
// Collapse into nextGroupMode() from sidebar-view-store once #2504 lands.
const target: SidebarGroupMode = source.groupMode === "project" ? "status" : "project";
return {
id: "sidebar-grouping",
group: "actions",
groupRank: 0,
rank: 8,
keywords: ["group", "grouping", "sort", "sidebar", "project", "status"],
visibility: "query",
run: () => source.setGroupMode(target),
presentation: {
kind: "action",
title: target === "status" ? source.labels.groupByStatus : source.labels.groupByProject,
sectionTitle: source.labels.section,
icon: target === "status" ? source.icons.status : source.icons.project,
},
};
}
@@ -3,6 +3,8 @@ import { router, type Href } from "expo-router";
import { useTranslation } from "react-i18next";
import {
CalendarClock,
CircleDashed,
Folder,
FolderPlus,
History,
Home,
@@ -18,6 +20,7 @@ import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import {
buildOpenProjectRoute,
@@ -28,6 +31,7 @@ import {
import { getShortcutOs } from "@/utils/shortcut-platform";
import type { CommandCenterContribution, CommandCenterIconProps } from "./contributions";
import { useCommandCenterActions } from "./provider";
import { buildGroupingContribution } from "./root-contributions";
const ThemedPlus = withUnistyles(Plus, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedFolderPlus = withUnistyles(FolderPlus, (theme) => ({
@@ -46,6 +50,10 @@ const ThemedSettings = withUnistyles(Settings, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedHome = withUnistyles(Home, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedFolder = withUnistyles(Folder, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedCircleDashed = withUnistyles(CircleDashed, (theme) => ({
color: theme.colors.foregroundMuted,
}));
function PlusIcon({ size }: CommandCenterIconProps) {
return <ThemedPlus size={size} strokeWidth={2.4} />;
@@ -75,6 +83,14 @@ function HomeIcon({ size }: CommandCenterIconProps) {
return <ThemedHome size={size} strokeWidth={2.2} />;
}
function FolderIcon({ size }: CommandCenterIconProps) {
return <ThemedFolder size={size} strokeWidth={2.2} />;
}
function CircleDashedIcon({ size }: CommandCenterIconProps) {
return <ThemedCircleDashed size={size} strokeWidth={2.2} />;
}
export function CommandCenterRootActions() {
const { t } = useTranslation();
const { overrides } = useKeyboardShortcutOverrides();
@@ -85,6 +101,10 @@ export function CommandCenterRootActions() {
const sessionsRoute = useMemo<Href>(() => buildSessionsRoute(), []);
const schedulesRoute = useMemo<Href>(() => buildSchedulesRoute(), []);
const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen);
// Narrow selector on purpose: a whole-store subscription would re-register every root action
// each time host filters are reconciled.
const groupMode = useSidebarViewStore((state) => state.groupMode);
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
const shortcutPlatform = useMemo(
() => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }),
[],
@@ -229,13 +249,28 @@ export function CommandCenterRootActions() {
});
}
availableActions.push(
buildGroupingContribution({
groupMode,
labels: {
section: t("shell.commandCenter.actions"),
groupByProject: t("shell.commandCenter.groupByProject"),
groupByStatus: t("shell.commandCenter.groupByStatus"),
},
icons: { project: FolderIcon, status: CircleDashedIcon },
setGroupMode,
}),
);
return availableActions;
}, [
groupMode,
homeRoute,
openAddProject,
overrides,
schedulesRoute,
sessionsRoute,
setGroupMode,
setShortcutsDialogOpen,
settingsRoute,
shortcutPlatform,
@@ -128,9 +128,13 @@ const styles = StyleSheet.create((theme) => ({
// Horizontal padding matches the model picker's row indent: the picker uses
// children mode (desktopChildrenScrollContent, no scroll padding), so the
// row content starts at item.paddingHorizontal = spacing[3].
// The search row below owns the gap under the title: the input already
// carries its own vertical padding, so a paddingBottom here would stack on
// top of two more and push the title far off the field.
inlineHeaderRow: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
@@ -3,6 +3,7 @@ import { Pressable, Text, View, type PressableStateCallbackType } from "react-na
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { AgentProfilePicker } from "@/agent-profiles";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Combobox, type ComboboxOption, type ComboboxProps } from "@/components/ui/combobox";
@@ -12,7 +13,6 @@ import type { ProviderSelectorProvider } from "@/provider-selection/provider-sel
import { ICON_SIZE, type Theme } from "@/styles/theme";
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
const EMPTY_FAVORITE_KEYS = new Set<string>();
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedMapping = (theme: Theme) => ({
@@ -27,8 +27,9 @@ interface CombinedModelSelectorProps {
selectedModel: string;
onSelect: (provider: AgentProvider, modelId: string) => void;
isLoading: boolean;
favoriteKeys?: Set<string>;
onToggleFavorite?: (provider: string, modelId: string) => void;
profiles?: AgentProfilePicker | null;
onApplyProfile?: (profileId: string) => void;
onEditProfiles?: () => void;
renderTrigger?: (input: {
selectedModelLabel: string;
onPress: () => void;
@@ -66,8 +67,9 @@ export function CombinedModelSelector({
selectedModel,
onSelect,
isLoading,
favoriteKeys = EMPTY_FAVORITE_KEYS,
onToggleFavorite,
profiles = null,
onApplyProfile,
onEditProfiles,
renderTrigger,
onOpen,
onClose,
@@ -89,7 +91,7 @@ export function CombinedModelSelector({
selectedProvider,
selectedModel,
isLoading,
favoriteKeys,
profiles,
serverId,
});
const { prepareToOpen, reset } = browser;
@@ -153,13 +155,28 @@ export function CombinedModelSelector({
[disabled, isOpen, renderTrigger, triggerFill],
);
const handleApplyProfile = useCallback(
(profileId: string) => {
onApplyProfile?.(profileId);
handleOpenChange(false);
},
[handleOpenChange, onApplyProfile],
);
const handleEditProfiles = useCallback(() => {
handleOpenChange(false);
onEditProfiles?.();
}, [handleOpenChange, onEditProfiles]);
const selectorBody = isContentReady ? (
<ModelBrowser
state={browser}
onSelect={handleSelect}
onToggleFavorite={onToggleFavorite}
onApplyProfile={handleApplyProfile}
onEditProfiles={onEditProfiles ? handleEditProfiles : undefined}
onRetryProvider={onRetryProvider}
isRetryingProvider={isRetryingProvider}
scrolling={isWeb ? "independent" : "sheet"}
/>
) : (
<View style={styles.sheetLoadingState}>
@@ -230,7 +247,9 @@ export function CombinedModelSelector({
anchorRef={anchorRef}
desktopPlacement={desktopPlacement}
desktopMinWidth={desktopMinWidth}
desktopLockWidth
desktopFixedHeight={browser.desktopFixedHeight}
desktopChildrenScrollEnabled={false}
header={browser.header}
mobileChildrenScrollEnabled={!browser.isProviderView || !isNative}
mobileChildrenContentContainerStyle={styles.mobileBrowserContent}
+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>
+195 -47
View File
@@ -1,6 +1,20 @@
import { useMemo, type ReactElement, type ReactNode } from "react";
import { Fragment, useMemo, type ReactElement, type ReactNode } from "react";
import { withUnistyles } from "react-native-unistyles";
import { Copy, Download, FileText, MessageSquarePlus, type LucideIcon } from "lucide-react-native";
import {
Copy,
CopyPlus,
Download,
FilePlus,
FileText,
FolderMinus,
FolderOpen,
FolderPlus,
MessageSquarePlus,
Pencil,
Trash2,
Undo2,
type LucideIcon,
} from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
@@ -10,11 +24,14 @@ import {
} from "@/components/ui/context-menu";
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
interface FileAction {
key: string;
label: string;
icon: LucideIcon;
onSelect: () => void;
destructive?: boolean;
separatorBefore?: boolean;
testID?: string;
}
@@ -23,8 +40,18 @@ interface FileActionsContextMenuContentProps {
fileExists?: boolean;
onOpenFile?: () => void;
onCopyPath?: () => void;
onCopyRelativePath?: () => void;
onReveal?: () => void;
revealTargetName?: string;
onDownload?: () => void;
onAddToChat?: () => void;
onNewFile?: () => void;
onNewFolder?: () => void;
onCollapseFolder?: () => void;
onRename?: () => void;
onDuplicate?: () => void;
onRevert?: () => void;
onDelete?: () => void;
/** Optional metadata block rendered above the actions (e.g. size/modified). */
header?: ReactNode;
testIDPrefix?: string;
@@ -39,51 +66,161 @@ export function FileActionsContextMenuContent({
fileExists = true,
onOpenFile,
onCopyPath,
onCopyRelativePath,
onReveal,
revealTargetName,
onDownload,
onAddToChat,
onNewFile,
onNewFolder,
onCollapseFolder,
onRename,
onDuplicate,
onRevert,
onDelete,
header,
testIDPrefix,
}: FileActionsContextMenuContentProps): ReactElement | null {
const { t } = useTranslation();
const actions = useMemo<FileAction[]>(() => {
const availableFile = fileKind === "file" && fileExists;
const next: FileAction[] = [];
if (availableFile && onOpenFile) {
next.push({
key: "open-file",
label: t("workspace.fileActions.openFile"),
icon: FileText,
onSelect: onOpenFile,
testID: testIDPrefix ? `${testIDPrefix}-open-file` : undefined,
});
}
if (onCopyPath) {
next.push({
key: "copy-path",
label: t("workspace.fileActions.copyPath"),
icon: Copy,
onSelect: onCopyPath,
});
}
if (availableFile && onDownload) {
next.push({
key: "download",
label: t("workspace.fileActions.download"),
icon: Download,
onSelect: onDownload,
});
}
if (availableFile && onAddToChat) {
next.push({
key: "add-to-chat",
label: t("workspace.fileActions.addToChat"),
icon: MessageSquarePlus,
onSelect: onAddToChat,
testID: testIDPrefix ? `${testIDPrefix}-add-to-chat` : undefined,
});
}
return next;
}, [fileExists, fileKind, onAddToChat, onCopyPath, onDownload, onOpenFile, t, testIDPrefix]);
const specs: Array<FileAction | null> = [
onNewFile
? {
key: "new-file",
label: t("workspace.fileActions.newFile"),
icon: FilePlus,
onSelect: onNewFile,
}
: null,
onNewFolder
? {
key: "new-folder",
label: t("workspace.fileActions.newFolder"),
icon: FolderPlus,
onSelect: onNewFolder,
}
: null,
onCollapseFolder
? {
key: "collapse-folder",
label: t("workspace.fileActions.collapseFolder"),
icon: FolderMinus,
onSelect: onCollapseFolder,
}
: null,
availableFile && onOpenFile
? {
key: "open-file",
label: t("workspace.fileActions.openFile"),
icon: FileText,
onSelect: onOpenFile,
}
: null,
onRename
? {
key: "rename",
label: t("workspace.fileActions.rename"),
icon: Pencil,
onSelect: onRename,
}
: null,
onDuplicate
? {
key: "duplicate",
label: t("workspace.fileActions.duplicate"),
icon: CopyPlus,
onSelect: onDuplicate,
}
: null,
onCopyPath
? {
key: "copy-path",
label: t("workspace.fileActions.copyPath"),
icon: Copy,
onSelect: onCopyPath,
}
: null,
onCopyRelativePath
? {
key: "copy-relative-path",
label: t("workspace.fileActions.copyRelativePath"),
icon: Copy,
onSelect: onCopyRelativePath,
}
: null,
onReveal && revealTargetName
? {
key: "reveal",
label: t("workspace.fileActions.revealIn", { target: revealTargetName }),
icon: FolderOpen,
onSelect: onReveal,
}
: null,
availableFile && onDownload
? {
key: "download",
label: t("workspace.fileActions.download"),
icon: Download,
onSelect: onDownload,
}
: null,
availableFile && onAddToChat
? {
key: "add-to-chat",
label: t("workspace.fileActions.addToChat"),
icon: MessageSquarePlus,
onSelect: onAddToChat,
}
: null,
onRevert
? {
key: "revert",
label: t("workspace.fileActions.revert"),
icon: Undo2,
onSelect: onRevert,
destructive: true,
}
: null,
onDelete
? {
key: "delete",
label: t("workspace.fileActions.delete"),
icon: Trash2,
onSelect: onDelete,
destructive: true,
}
: null,
];
const availableActions = specs.filter((action): action is FileAction => action !== null);
return availableActions.map((action, index) =>
Object.assign(action, {
separatorBefore: Boolean(
action.destructive && index > 0 && !availableActions[index - 1].destructive,
),
testID: testIDPrefix ? `${testIDPrefix}-${action.key}` : undefined,
}),
);
}, [
fileExists,
fileKind,
onAddToChat,
onCollapseFolder,
onCopyPath,
onCopyRelativePath,
onDelete,
onDownload,
onDuplicate,
onNewFile,
onNewFolder,
onOpenFile,
onRename,
onReveal,
onRevert,
revealTargetName,
t,
testIDPrefix,
]);
if (actions.length === 0) {
return null;
@@ -101,21 +238,32 @@ export function FileActionsContextMenuContent({
</>
) : null}
{actions.map((action) => (
<FileActionMenuItem key={action.key} action={action} />
<Fragment key={action.key}>
{action.separatorBefore ? <ContextMenuSeparator /> : null}
<FileActionMenuItem action={action} />
</Fragment>
))}
</ContextMenuContent>
);
}
function FileActionMenuItem({ action }: { action: FileAction }): ReactElement {
const Icon = action.icon;
const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]);
const leading = useMemo(
() => <ThemedIcon size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />,
[ThemedIcon],
);
const leading = useMemo(() => {
const ThemedIcon = withUnistyles(action.icon);
return (
<ThemedIcon
size={ICON_SIZE.sm}
uniProps={action.destructive ? destructiveColorMapping : foregroundMutedColorMapping}
/>
);
}, [action.destructive, action.icon]);
return (
<ContextMenuItem leading={leading} onSelect={action.onSelect} testID={action.testID}>
<ContextMenuItem
leading={leading}
onSelect={action.onSelect}
destructive={action.destructive}
testID={action.testID}
>
{action.label}
</ContextMenuItem>
);
File diff suppressed because it is too large Load Diff
+36 -1
View File
@@ -2,6 +2,7 @@ import { router, usePathname } from "expo-router";
import {
CalendarClock,
FolderPlus,
GitBranch,
History,
Home,
Plus,
@@ -77,6 +78,8 @@ import { SidebarWorkspaceList } from "./sidebar-workspace-list";
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null;
interface SidebarSharedProps {
theme: SidebarTheme;
statusGroups: StatusGroup[];
@@ -853,9 +856,22 @@ function DesktopSidebar({
>
<View style={desktopSidebarBorderStyle}>
<View style={styles.sidebarDragArea}>
{ownsTopLeft ? (
{ownsTopLeft || DEV_BUILD_LABEL ? (
<View style={styles.desktopChromeRow}>
<TitlebarDragRegion />
{DEV_BUILD_LABEL ? (
<View
pointerEvents="none"
style={styles.devBuildBadge}
testID="dev-build-label"
accessibilityLabel={`Development build: ${DEV_BUILD_LABEL}`}
>
<GitBranch size={12} color={theme.colors.accentForeground} />
<Text numberOfLines={1} ellipsizeMode="tail" style={styles.devBuildBadgeText}>
{DEV_BUILD_LABEL}
</Text>
</View>
) : null}
</View>
) : (
<TitlebarDragRegion />
@@ -1080,9 +1096,28 @@ const styles = StyleSheet.create((theme) => ({
height: HEADER_INNER_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-end",
paddingHorizontal: theme.spacing[3],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: "transparent",
},
devBuildBadge: {
maxWidth: "60%",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: 2,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
},
devBuildBadgeText: {
minWidth: 0,
flexShrink: 1,
color: theme.colors.accentForeground,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
},
sidebarFooter: {
flexDirection: "row",
alignItems: "center",
@@ -0,0 +1,42 @@
import type { ComponentType } from "react";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { getMarkdownFenceLanguage } from "./language";
import { MermaidFence } from "./mermaid";
import type { MarkdownFenceRendererProps } from "./types";
export interface MarkdownFenceBlockProps extends MarkdownFenceRendererProps {
info: string | null | undefined;
}
const diagramFences: Partial<Record<string, ComponentType<MarkdownFenceRendererProps>>> = {
mermaid: MermaidFence,
};
export function MarkdownFenceBlock({
code,
info,
phase,
inheritedStyles,
textStyle,
}: MarkdownFenceBlockProps) {
const language = getMarkdownFenceLanguage(info);
const DiagramFence = language ? diagramFences[language] : undefined;
if (DiagramFence) {
return (
<DiagramFence
code={code}
phase={phase}
inheritedStyles={inheritedStyles}
textStyle={textStyle}
/>
);
}
return (
<HighlightedCodeBlock
code={code}
language={language}
inheritedStyles={inheritedStyles}
textStyle={textStyle}
/>
);
}
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { getMarkdownFenceLanguage } from "./language";
describe("getMarkdownFenceLanguage", () => {
it("normalizes the first info-string token", () => {
expect(getMarkdownFenceLanguage(" Sirena theme=dark ")).toBe("sirena");
expect(getMarkdownFenceLanguage("TypeScript title=example")).toBe("typescript");
});
it("returns null when no language is declared", () => {
expect(getMarkdownFenceLanguage("")).toBeNull();
expect(getMarkdownFenceLanguage(null)).toBeNull();
expect(getMarkdownFenceLanguage(undefined)).toBeNull();
});
});
@@ -0,0 +1,3 @@
export function getMarkdownFenceLanguage(info: string | null | undefined): string | null {
return info?.trim().split(/\s+/)[0]?.toLowerCase() || null;
}
@@ -0,0 +1,76 @@
import esbuild from "esbuild";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const appRoot = path.resolve(__dirname, "../../../../..");
const repoRoot = path.resolve(appRoot, "../..");
const entry = path.join(__dirname, "runtime/entry.ts");
const output = path.join(__dirname, "runtime/html.gen.ts");
const result = await esbuild.build({
entryPoints: [entry],
bundle: true,
write: false,
format: "iife",
platform: "browser",
target: ["ios15", "chrome100"],
minify: true,
// The app's deployment floor is iOS 15.1 but structuredClone (used by
// mermaid) only exists from iOS 15.4; esbuild targets transform syntax,
// not runtime APIs. Mermaid only clones plain config objects, so a
// JSON-based fallback is sufficient.
banner: {
js: 'if(typeof globalThis.structuredClone!=="function"){globalThis.structuredClone=function(v){return v===undefined?v:JSON.parse(JSON.stringify(v));};}',
},
logLevel: "info",
});
const rawJs = result.outputFiles[0]?.text;
if (!rawJs) {
throw new Error("Mermaid runtime bundle produced no JavaScript");
}
// JSON.stringify below escapes the TypeScript representation, not the runtime
// HTML — an embedded closing-script sequence would still terminate the inline
// <script>. `<\/script` is identical to `</script` inside JS strings/regex.
const js = rawJs.replace(/<\/script/gi, "<\\/script");
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'none'; img-src data: blob:; font-src 'none'; media-src 'none'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'"
/>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<style>
html,
body {
margin: 0;
padding: 0;
background: transparent;
}
#diagram svg {
display: block;
}
</style>
</head>
<body>
<div id="diagram"></div>
<script>${js}</script>
</body>
</html>`;
const contents = `// Generated by packages/app/src/components/markdown/fence/mermaid/build-runtime.mjs.
// Do not edit by hand.
export const mermaidRuntimeHtml = ${JSON.stringify(html)};
`;
await fs.writeFile(output, contents);
console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
@@ -0,0 +1,379 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Modal,
Pressable,
StyleSheet as RNStyleSheet,
ScrollView,
View,
type TextStyle,
type ViewStyle,
} from "react-native";
import { WebView, type WebViewMessageEvent } from "react-native-webview";
import { Code, Workflow, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import type { Theme } from "@/styles/theme";
import type { MarkdownFenceRendererProps } from "../types";
import type { MermaidRenderRequest } from "./render-model";
import { mermaidRuntimeHtml } from "./runtime/html.gen";
import {
parseMermaidRuntimeMessage,
serializeMermaidRuntimeRenderMessage,
type MermaidRuntimeRenderMessage,
} from "./runtime/messages";
import { MermaidRuntimeRequestDriver } from "./runtime/request-driver";
import { useMermaidRenderModel } from "./use-render-model";
import { getDiagramBoxStyle } from "./presentation";
interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps {
colorScheme?: "light" | "dark";
}
const WEBVIEW_SOURCE = { html: mermaidRuntimeHtml };
const WEBVIEW_ORIGIN_WHITELIST = ["*"];
const MAX_PREVIEW_HEIGHT = 480;
interface MermaidWebViewProps {
request: MermaidRenderRequest | null;
interactive: boolean;
onRendered: (message: {
revision: number;
source: string;
colorScheme: "light" | "dark";
height: number;
width: number;
}) => void;
onRenderFailed: (revision: number) => void;
style?: ViewStyle;
}
function MermaidWebView({
request,
interactive,
onRendered,
onRenderFailed,
style,
}: MermaidWebViewProps) {
const webViewRef = useRef<WebView | null>(null);
const driverRef = useRef<MermaidRuntimeRequestDriver | null>(null);
driverRef.current ??= new MermaidRuntimeRequestDriver();
const sendRequest = useCallback(
(current: MermaidRenderRequest | null) => {
if (!current || !webViewRef.current) {
return;
}
const message: MermaidRuntimeRenderMessage = {
type: "render",
revision: current.revision,
source: current.source,
colorScheme: current.colorScheme,
interactive,
};
const payload = serializeMermaidRuntimeRenderMessage(message);
webViewRef.current.injectJavaScript(
`window.__PASEO_MERMAID_RUNTIME_RECEIVE__ && window.__PASEO_MERMAID_RUNTIME_RECEIVE__(${payload}); true;`,
);
},
[interactive],
);
useEffect(() => {
sendRequest(driverRef.current?.update(request) ?? null);
}, [request, sendRequest]);
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
let value: unknown;
try {
value = JSON.parse(event.nativeEvent.data);
} catch {
return;
}
const message = parseMermaidRuntimeMessage(value);
if (!message) {
return;
}
if (message.type === "bridgeReady") {
sendRequest(driverRef.current?.ready() ?? null);
return;
}
if (message.type === "renderError") {
onRenderFailed(message.revision);
sendRequest(driverRef.current?.settled(message.revision, false) ?? null);
return;
}
onRendered(message);
sendRequest(driverRef.current?.settled(message.revision, true) ?? null);
},
[onRenderFailed, onRendered, sendRequest],
);
const handleShouldStartLoad = useCallback(
(load: { url: string }) => load.url === "about:blank" || load.url.startsWith("data:"),
[],
);
return (
<WebView
ref={webViewRef}
source={WEBVIEW_SOURCE}
originWhitelist={WEBVIEW_ORIGIN_WHITELIST}
style={[webViewStyles.webView, style]}
onMessage={handleMessage}
onShouldStartLoadWithRequest={handleShouldStartLoad}
scrollEnabled={interactive}
bounces={false}
scalesPageToFit={interactive}
setSupportMultipleWindows={false}
allowsLinkPreview={false}
javaScriptEnabled
/>
);
}
const webViewStyles = RNStyleSheet.create({
webView: { backgroundColor: "transparent" },
});
interface MermaidDiagramViewerProps {
code: string;
colorScheme: "light" | "dark";
onClose: () => void;
inheritedStyles: TextStyle;
textStyle: TextStyle;
}
function MermaidDiagramViewer({
code,
colorScheme,
onClose,
inheritedStyles,
textStyle,
}: MermaidDiagramViewerProps) {
const { t } = useTranslation();
const [showSource, setShowSource] = useState(false);
const toggleSource = useCallback(() => setShowSource((current) => !current), []);
const { request, rendered, renderFailed } = useMermaidRenderModel({
source: code,
phase: "complete",
colorScheme,
});
const handleRendered = useCallback(
(message: {
revision: number;
source: string;
colorScheme: "light" | "dark";
height: number;
width: number;
}) => {
rendered({
revision: message.revision,
source: message.source,
colorScheme: message.colorScheme,
dimensions: { height: message.height, width: message.width },
});
},
[rendered],
);
return (
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
<View style={viewerStyles.backdrop}>
<View style={viewerStyles.webView}>
<MermaidWebView
request={request}
interactive
onRendered={handleRendered}
onRenderFailed={renderFailed}
style={viewerStyles.runtime}
/>
{showSource ? (
<ScrollView
style={viewerStyles.sourceOverlay}
contentContainerStyle={viewerStyles.source}
>
<HighlightedCodeBlock
code={code}
language="mermaid"
inheritedStyles={inheritedStyles}
textStyle={textStyle}
/>
</ScrollView>
) : null}
</View>
<View style={viewerStyles.actions}>
<Pressable
onPress={toggleSource}
style={viewerStyles.actionButton}
accessibilityRole="button"
accessibilityLabel={
showSource ? t("message.diagram.viewDiagram") : t("message.diagram.viewSource")
}
hitSlop={12}
>
{showSource ? (
<ThemedDiagramIcon size={20} uniProps={closeIconColor} />
) : (
<ThemedSourceIcon size={20} uniProps={closeIconColor} />
)}
</Pressable>
<Pressable
onPress={onClose}
style={viewerStyles.actionButton}
accessibilityRole="button"
accessibilityLabel={t("common.actions.close")}
hitSlop={12}
>
<ThemedCloseIcon size={20} uniProps={closeIconColor} />
</Pressable>
</View>
</View>
</Modal>
);
}
const ThemedCloseIcon = withUnistyles(X);
const ThemedSourceIcon = withUnistyles(Code);
const ThemedDiagramIcon = withUnistyles(Workflow);
const closeIconColor = (theme: Theme) => ({ color: theme.colors.foreground });
const viewerStyles = StyleSheet.create((theme, runtime) => ({
backdrop: { flex: 1, backgroundColor: theme.colors.surface0 },
webView: {
flex: 1,
marginTop: runtime.insets.top,
marginBottom: runtime.insets.bottom,
},
runtime: { flex: 1 },
sourceOverlay: {
position: "absolute",
inset: 0,
backgroundColor: theme.colors.surface0,
},
source: {
paddingTop: theme.spacing[12],
paddingHorizontal: theme.spacing[4],
paddingBottom: theme.spacing[4],
},
actions: {
position: "absolute",
top: runtime.insets.top + theme.spacing[3],
right: theme.spacing[4],
flexDirection: "row",
gap: theme.spacing[2],
},
actionButton: {
padding: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
},
}));
function MermaidFenceHostImpl({
code,
phase,
inheritedStyles,
textStyle,
colorScheme = "dark",
}: MermaidFenceHostImplProps) {
const { t } = useTranslation();
const { state, request, rendered, renderFailed } = useMermaidRenderModel({
source: code,
phase,
colorScheme,
});
const [hasRuntimeContent, setHasRuntimeContent] = useState(false);
const handleRendered = useCallback(
(message: {
revision: number;
source: string;
colorScheme: "light" | "dark";
height: number;
width: number;
}) => {
setHasRuntimeContent(true);
rendered({
revision: message.revision,
source: message.source,
colorScheme: message.colorScheme,
dimensions: { height: message.height, width: message.width },
});
},
[rendered],
);
const [viewerOpen, setViewerOpen] = useState(false);
const openViewer = useCallback(() => setViewerOpen(true), []);
const closeViewer = useCallback(() => setViewerOpen(false), []);
const visible = state.visible;
const canShowDiagram = visible !== null && hasRuntimeContent;
const previewInnerStyle = useMemo(
() =>
canShowDiagram && visible
? { height: Math.min(visible.height, MAX_PREVIEW_HEIGHT) }
: previewStyles.measuringInner,
[canShowDiagram, visible],
);
const diagramBoxStyle = getDiagramBoxStyle(textStyle);
return (
<>
{!canShowDiagram ? (
<HighlightedCodeBlock
code={code}
language="mermaid"
inheritedStyles={inheritedStyles}
textStyle={textStyle}
/>
) : null}
<Pressable
onPress={openViewer}
disabled={!canShowDiagram}
accessibilityRole={canShowDiagram ? "imagebutton" : undefined}
accessibilityLabel={t("message.diagram.diagram")}
style={
canShowDiagram
? [diagramBoxStyle, previewStyles.preview]
: [diagramBoxStyle, previewStyles.measuring]
}
>
<View style={previewInnerStyle} pointerEvents="none">
<MermaidWebView
request={request}
interactive={false}
onRendered={handleRendered}
onRenderFailed={renderFailed}
/>
</View>
</Pressable>
{viewerOpen && canShowDiagram && visible ? (
<MermaidDiagramViewer
code={visible.source}
colorScheme={visible.colorScheme}
onClose={closeViewer}
inheritedStyles={inheritedStyles}
textStyle={textStyle}
/>
) : null}
</>
);
}
const previewStyles = RNStyleSheet.create({
measuring: {
position: "absolute",
left: 0,
right: 0,
opacity: 0,
pointerEvents: "none",
},
measuringInner: { height: 240 },
preview: { overflow: "hidden" },
});
const mapColorScheme = (theme: Theme) => ({ colorScheme: theme.colorScheme });
const ThemedMermaidFenceHost = withUnistyles(MermaidFenceHostImpl);
export function MermaidFenceHost(props: MarkdownFenceRendererProps) {
return <ThemedMermaidFenceHost {...props} uniProps={mapColorScheme} />;
}
@@ -0,0 +1,13 @@
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import type { MarkdownFenceRendererProps } from "../types";
export function MermaidFenceHost({ code, inheritedStyles, textStyle }: MarkdownFenceRendererProps) {
return (
<HighlightedCodeBlock
code={code}
language="mermaid"
inheritedStyles={inheritedStyles}
textStyle={textStyle}
/>
);
}
@@ -0,0 +1,490 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
} from "react";
import { Pressable, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import { useTranslation } from "react-i18next";
import { Code, Scan, Workflow, ZoomIn, ZoomOut } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { useIsCompactFormFactor } from "@/constants/layout";
import type { Theme } from "@/styles/theme";
import type { MarkdownFenceRendererProps } from "../types";
import type { MermaidRenderRequest } from "./render-model";
import { mermaidRuntimeHtml } from "./runtime/html.gen";
import { parseMermaidRuntimeMessage, type MermaidRuntimeRenderMessage } from "./runtime/messages";
import { MermaidRuntimeRequestDriver } from "./runtime/request-driver";
import { useMermaidRenderModel } from "./use-render-model";
import { getDiagramBoxStyle } from "./presentation";
interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps {
colorScheme?: "light" | "dark";
}
interface MermaidIframeRuntimeProps {
request: MermaidRenderRequest | null;
height: number;
onRendered: (message: {
revision: number;
source: string;
colorScheme: "light" | "dark";
height: number;
width: number;
}) => void;
onRenderFailed: (revision: number) => void;
}
function MermaidIframeRuntime({
request,
height,
onRendered,
onRenderFailed,
}: MermaidIframeRuntimeProps) {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const driverRef = useRef<MermaidRuntimeRequestDriver | null>(null);
driverRef.current ??= new MermaidRuntimeRequestDriver();
const iframeStyle = useMemo<React.CSSProperties>(
() => ({
display: "block",
width: "100%",
height,
border: 0,
pointerEvents: "none",
background: "transparent",
}),
[height],
);
const sendRequest = useCallback((current: MermaidRenderRequest | null) => {
const target = iframeRef.current?.contentWindow;
if (!current || !target) {
return;
}
const message: MermaidRuntimeRenderMessage = {
type: "render",
revision: current.revision,
source: current.source,
colorScheme: current.colorScheme,
interactive: false,
};
target.postMessage(message, "*");
}, []);
useEffect(() => {
sendRequest(driverRef.current?.update(request) ?? null);
}, [request, sendRequest]);
useEffect(() => {
function receiveMessage(event: MessageEvent): void {
if (event.source !== iframeRef.current?.contentWindow) {
return;
}
const message = parseMermaidRuntimeMessage(event.data);
if (!message) {
return;
}
if (message.type === "bridgeReady") {
sendRequest(driverRef.current?.ready() ?? null);
return;
}
if (message.type === "renderError") {
onRenderFailed(message.revision);
sendRequest(driverRef.current?.settled(message.revision, false) ?? null);
return;
}
onRendered(message);
sendRequest(driverRef.current?.settled(message.revision, true) ?? null);
}
window.addEventListener("message", receiveMessage);
return () => window.removeEventListener("message", receiveMessage);
}, [onRenderFailed, onRendered, sendRequest]);
return (
<iframe
ref={iframeRef}
title=""
aria-hidden
sandbox="allow-scripts"
srcDoc={mermaidRuntimeHtml}
tabIndex={-1}
style={iframeStyle}
/>
);
}
const MIN_SCALE = 0.25;
const MAX_SCALE = 8;
function MermaidFenceHostImpl({
code,
phase,
inheritedStyles,
textStyle,
colorScheme = "dark",
}: MermaidFenceHostImplProps) {
const { t } = useTranslation();
const { state, request, rendered, renderFailed } = useMermaidRenderModel({
source: code,
phase,
colorScheme,
});
const [hasRuntimeContent, setHasRuntimeContent] = useState(false);
const handleRendered = useCallback(
(message: {
revision: number;
source: string;
colorScheme: "light" | "dark";
height: number;
width: number;
}) => {
setHasRuntimeContent(true);
rendered({
revision: message.revision,
source: message.source,
colorScheme: message.colorScheme,
dimensions: { height: message.height, width: message.width },
});
},
[rendered],
);
const visible = state.visible;
const canShowDiagram = visible !== null && hasRuntimeContent;
const runtimeHeight = Math.max(visible?.height ?? 240, 1);
const viewportRef = useRef<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const controlsRegionRef = useRef<HTMLDivElement | null>(null);
const transformRef = useRef({ x: 0, y: 0, scale: 1 });
const dragRef = useRef<{
pointerId: number;
startX: number;
startY: number;
originX: number;
originY: number;
} | null>(null);
const applyTransformToElement = useCallback(
(element: HTMLDivElement, transform: { x: number; y: number; scale: number }) => {
const { x, y, scale } = transform;
element.style.transform = `translate(${x}px, ${y}px) scale(${scale})`;
},
[],
);
const applyTransform = useCallback(() => {
if (contentRef.current) {
applyTransformToElement(contentRef.current, transformRef.current);
}
}, [applyTransformToElement]);
const setContentRef = useCallback(
(node: HTMLDivElement | null) => {
contentRef.current = node;
if (node) {
applyTransformToElement(node, transformRef.current);
}
},
[applyTransformToElement],
);
const setControlsRegionRef = useCallback((node: unknown) => {
controlsRegionRef.current = node instanceof HTMLDivElement ? node : null;
}, []);
const hasFocusedControls = useCallback(() => {
const region = controlsRegionRef.current;
const active = document.activeElement;
return Boolean(region && active instanceof Node && region.contains(active));
}, []);
const [isFocusWithin, setIsFocusWithin] = useState(false);
const handleFocusWithin = useCallback(() => setIsFocusWithin(true), []);
const handleBlurWithin = useCallback(() => {
window.requestAnimationFrame(() => setIsFocusWithin(hasFocusedControls()));
}, [hasFocusedControls]);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport || !visible) {
return;
}
const activeViewport = viewport;
function zoomWithWheel(event: WheelEvent): void {
if (!event.ctrlKey && !event.metaKey) {
return;
}
event.preventDefault();
const transform = transformRef.current;
const factor = Math.min(1.25, Math.max(0.8, Math.exp(-event.deltaY * 0.01)));
const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, transform.scale * factor));
const rect = activeViewport.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
transform.x = x - ((x - transform.x) / transform.scale) * scale;
transform.y = y - ((y - transform.y) / transform.scale) * scale;
transform.scale = scale;
applyTransform();
}
viewport.addEventListener("wheel", zoomWithWheel, { passive: false });
return () => viewport.removeEventListener("wheel", zoomWithWheel);
}, [applyTransform, visible]);
const onPointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || event.pointerType !== "mouse") {
return;
}
const transform = transformRef.current;
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: transform.x,
originY: transform.y,
};
event.currentTarget.setPointerCapture(event.pointerId);
}, []);
const onPointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || event.pointerId !== drag.pointerId) {
return;
}
transformRef.current.x = drag.originX + event.clientX - drag.startX;
transformRef.current.y = drag.originY + event.clientY - drag.startY;
applyTransform();
},
[applyTransform],
);
const onPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (dragRef.current?.pointerId === event.pointerId) {
dragRef.current = null;
}
}, []);
const resetTransform = useCallback(() => {
transformRef.current = { x: 0, y: 0, scale: 1 };
applyTransform();
}, [applyTransform]);
const zoomBy = useCallback(
(factor: number) => {
const viewport = viewportRef.current;
if (!viewport) {
return;
}
const rect = viewport.getBoundingClientRect();
const x = rect.width / 2;
const y = rect.height / 2;
const transform = transformRef.current;
const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, transform.scale * factor));
transform.x = x - ((x - transform.x) / transform.scale) * scale;
transform.y = y - ((y - transform.y) / transform.scale) * scale;
transform.scale = scale;
applyTransform();
},
[applyTransform],
);
const zoomInPress = useCallback(() => zoomBy(1.25), [zoomBy]);
const zoomOutPress = useCallback(() => zoomBy(0.8), [zoomBy]);
const [showSource, setShowSource] = useState(false);
const showSourcePress = useCallback(() => setShowSource(true), []);
const showDiagramPress = useCallback(() => setShowSource(false), []);
const [isHovered, setIsHovered] = useState(false);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const isCompact = useIsCompactFormFactor();
const controlsVisible = isHovered || isCompact || isFocusWithin;
useEffect(() => {
if (!showSource && contentRef.current) {
applyTransformToElement(contentRef.current, transformRef.current);
}
}, [applyTransformToElement, showSource]);
const sourceView = useMemo(() => {
const { marginTop, marginBottom, marginVertical, ...sourceTextStyle } = textStyle;
const margins: ViewStyle = {
marginTop: marginTop ?? marginVertical,
marginBottom: marginBottom ?? marginVertical,
};
const text: TextStyle = sourceTextStyle;
return {
container: [margins, sourceContainerStyle],
text,
};
}, [textStyle]);
const diagramBoxStyle = getDiagramBoxStyle(textStyle);
const diagramVisible = canShowDiagram && !showSource;
const sourceVisible = !canShowDiagram || showSource;
let rootStyle: StyleProp<ViewStyle> = sourceContainerStyle;
if (diagramVisible) {
rootStyle = [diagramBoxStyle, containerStyle];
} else if (showSource) {
rootStyle = sourceView.container;
}
const sourceTextStyle = showSource ? sourceView.text : textStyle;
const runtime = (
<MermaidIframeRuntime
request={request}
height={runtimeHeight}
onRendered={handleRendered}
onRenderFailed={renderFailed}
/>
);
return (
<View
ref={setControlsRegionRef}
style={rootStyle}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
onFocus={handleFocusWithin}
onBlur={handleBlurWithin}
>
{sourceVisible ? (
<HighlightedCodeBlock
code={code}
language="mermaid"
inheritedStyles={inheritedStyles}
textStyle={sourceTextStyle}
/>
) : null}
<div
ref={viewportRef}
role={diagramVisible ? "img" : undefined}
aria-label={diagramVisible ? t("message.diagram.diagram") : undefined}
aria-hidden={!diagramVisible}
style={diagramVisible ? viewportDomStyle : measuringRuntimeStyle}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onDoubleClick={resetTransform}
>
<div ref={setContentRef} style={contentDomStyle}>
{runtime}
</div>
</div>
{diagramVisible ? (
<View style={controlStyles.cluster}>
<DiagramControlButton
icon={ZoomIn}
label={t("message.diagram.zoomIn")}
onPress={zoomInPress}
visible={controlsVisible}
/>
<DiagramControlButton
icon={ZoomOut}
label={t("message.diagram.zoomOut")}
onPress={zoomOutPress}
visible={controlsVisible}
/>
<DiagramControlButton
icon={Scan}
label={t("message.diagram.resetZoom")}
onPress={resetTransform}
visible={controlsVisible}
/>
<DiagramControlButton
icon={Code}
label={t("message.diagram.viewSource")}
onPress={showSourcePress}
visible={controlsVisible}
/>
</View>
) : null}
{showSource && canShowDiagram ? (
<View style={[controlStyles.cluster, controlStyles.clusterSourceOffset]}>
<DiagramControlButton
icon={Workflow}
label={t("message.diagram.viewDiagram")}
onPress={showDiagramPress}
visible={controlsVisible}
plain
/>
</View>
) : null}
</View>
);
}
interface DiagramControlButtonProps {
icon: ComponentType<{ size?: number; color?: string }>;
label: string;
onPress: () => void;
visible: boolean;
plain?: boolean;
}
const DiagramControlButton = React.memo(function DiagramControlButton({
icon: Icon,
label,
onPress,
visible,
plain = false,
}: DiagramControlButtonProps) {
const pillStyle = visible ? controlStyles.button : controlStyles.buttonHidden;
const plainStyle = visible ? controlStyles.plainButton : controlStyles.plainButtonHidden;
return (
<Pressable
onPress={onPress}
style={plain ? plainStyle : pillStyle}
accessibilityRole="button"
accessibilityLabel={label}
hitSlop={4}
>
{({ hovered }) => (
<Icon
size={14}
color={hovered ? controlStyles.iconHovered.color : controlStyles.icon.color}
/>
)}
</Pressable>
);
});
const controlStyles = StyleSheet.create((theme) => ({
cluster: {
position: "absolute",
top: theme.spacing[2],
right: theme.spacing[2],
flexDirection: "row",
gap: theme.spacing[1],
},
clusterSourceOffset: { right: 40 },
button: {
padding: theme.spacing[1],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
opacity: 1,
pointerEvents: "auto",
},
buttonHidden: {
padding: theme.spacing[1],
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
opacity: 0,
pointerEvents: "none",
},
plainButton: { padding: theme.spacing[1], opacity: 1, pointerEvents: "auto" },
plainButtonHidden: { padding: theme.spacing[1], opacity: 0, pointerEvents: "none" },
icon: { color: theme.colors.foregroundMuted },
iconHovered: { color: theme.colors.foreground },
}));
const sourceContainerStyle: ViewStyle = { position: "relative" };
const containerStyle: ViewStyle = { overflow: "hidden", position: "relative" };
const viewportDomStyle: React.CSSProperties = { cursor: "grab", userSelect: "none" };
const contentDomStyle: React.CSSProperties = { transformOrigin: "0 0" };
const measuringRuntimeStyle: React.CSSProperties = {
position: "absolute",
inset: 0,
opacity: 0,
pointerEvents: "none",
overflow: "hidden",
};
const mapColorScheme = (theme: Theme) => ({ colorScheme: theme.colorScheme });
const ThemedMermaidFenceHost = withUnistyles(MermaidFenceHostImpl);
export function MermaidFenceHost(props: MarkdownFenceRendererProps) {
return <ThemedMermaidFenceHost {...props} uniProps={mapColorScheme} />;
}
@@ -0,0 +1,6 @@
import type { MarkdownFenceRendererProps } from "../types";
import { MermaidFenceHost } from "./host";
export function MermaidFence(props: MarkdownFenceRendererProps) {
return <MermaidFenceHost {...props} />;
}
@@ -0,0 +1,14 @@
import type { TextStyle, ViewStyle } from "react-native";
export function getDiagramBoxStyle(style: TextStyle): ViewStyle {
return {
backgroundColor: style.backgroundColor,
borderColor: style.borderColor,
borderRadius: style.borderRadius,
borderWidth: style.borderWidth,
marginBottom: style.marginBottom,
marginTop: style.marginTop,
marginVertical: style.marginVertical,
padding: style.padding,
};
}
@@ -0,0 +1,163 @@
import { describe, expect, it } from "vitest";
import {
createMermaidRenderModel,
getMermaidRenderRequest,
reduceMermaidRenderModel,
type MermaidRenderInput,
type MermaidRenderModel,
} from "./render-model";
function input(overrides: Partial<MermaidRenderInput> = {}): MermaidRenderInput {
return {
source: "flowchart TD\nA --> B",
phase: "streaming",
colorScheme: "dark",
rejected: false,
cached: null,
...overrides,
};
}
function renderCurrent(state: MermaidRenderModel): MermaidRenderModel {
return reduceMermaidRenderModel(state, {
type: "rendered",
revision: state.revision,
source: state.source,
colorScheme: state.colorScheme,
dimensions: { height: 120, width: 240 },
});
}
describe("Mermaid render model", () => {
it("shows each valid streamed revision", () => {
const first = renderCurrent(createMermaidRenderModel(input()));
const secondPending = reduceMermaidRenderModel(first, {
type: "inputChanged",
input: input({ source: "flowchart TD\nA --> B\nB --> C" }),
});
const second = renderCurrent(secondPending);
expect(first.visible?.source).toBe("flowchart TD\nA --> B");
expect(second.visible?.source).toBe("flowchart TD\nA --> B\nB --> C");
});
it("keeps the last valid preview across a transient streaming failure", () => {
const rendered = renderCurrent(createMermaidRenderModel(input()));
const pending = reduceMermaidRenderModel(rendered, {
type: "inputChanged",
input: input({ source: "flowchart TD\nA --" }),
});
const failed = reduceMermaidRenderModel(pending, {
type: "renderFailed",
revision: pending.revision,
});
expect(failed.status).toBe("failed");
expect(failed.visible?.source).toBe("flowchart TD\nA --> B");
});
it("clears a stale preview when failed source becomes final", () => {
const rendered = renderCurrent(createMermaidRenderModel(input()));
const pending = reduceMermaidRenderModel(rendered, {
type: "inputChanged",
input: input({ source: "flowchart TD\nA --" }),
});
const failed = reduceMermaidRenderModel(pending, {
type: "renderFailed",
revision: pending.revision,
});
const complete = reduceMermaidRenderModel(failed, {
type: "inputChanged",
input: input({ source: "flowchart TD\nA --", phase: "complete" }),
});
expect(complete.status).toBe("failed");
expect(complete.visible).toBeNull();
});
it("uses the completion phase when an in-flight final render fails", () => {
const rendered = renderCurrent(createMermaidRenderModel(input()));
const pending = reduceMermaidRenderModel(rendered, {
type: "inputChanged",
input: input({ source: "flowchart TD\nA --" }),
});
const completePending = reduceMermaidRenderModel(pending, {
type: "inputChanged",
input: input({ source: "flowchart TD\nA --", phase: "complete" }),
});
const failed = reduceMermaidRenderModel(completePending, {
type: "renderFailed",
revision: completePending.revision,
});
expect(failed.visible).toBeNull();
});
it("ignores obsolete render results", () => {
const initial = createMermaidRenderModel(input());
const newer = reduceMermaidRenderModel(initial, {
type: "inputChanged",
input: input({ source: "flowchart TD\nA --> C" }),
});
const obsolete = reduceMermaidRenderModel(newer, {
type: "rendered",
revision: initial.revision,
source: initial.source,
colorScheme: initial.colorScheme,
dimensions: { height: 90, width: 180 },
});
expect(obsolete).toBe(newer);
expect(getMermaidRenderRequest(obsolete)).toEqual({
revision: newer.revision,
source: "flowchart TD\nA --> C",
colorScheme: "dark",
});
});
it("uses an obsolete successful prefix as the first streaming preview", () => {
const initial = createMermaidRenderModel(input());
const newer = reduceMermaidRenderModel(initial, {
type: "inputChanged",
input: input({ source: "flowchart TD\nA --> B\nB --" }),
});
const preview = reduceMermaidRenderModel(newer, {
type: "rendered",
revision: initial.revision,
source: initial.source,
colorScheme: initial.colorScheme,
dimensions: { height: 90, width: 180 },
});
expect(preview.status).toBe("pending");
expect(preview.visible?.source).toBe(initial.source);
expect(getMermaidRenderRequest(preview)?.source).toBe("flowchart TD\nA --> B\nB --");
});
it("keeps the last preview when source policy rejects a streaming revision", () => {
const rendered = renderCurrent(createMermaidRenderModel(input()));
const rejected = reduceMermaidRenderModel(rendered, {
type: "inputChanged",
input: input({ source: 'flowchart TD\nA@{ img: "https://example.com/x" }', rejected: true }),
});
expect(rejected.status).toBe("rejected");
expect(rejected.visible).toBe(rendered.visible);
expect(getMermaidRenderRequest(rejected)).toBeNull();
});
it("clears the last preview when the rejected revision is complete", () => {
const rendered = renderCurrent(createMermaidRenderModel(input()));
const rejected = reduceMermaidRenderModel(rendered, {
type: "inputChanged",
input: input({
source: 'flowchart TD\nA@{ img: "https://example.com/x" }',
phase: "complete",
rejected: true,
}),
});
expect(rejected.status).toBe("rejected");
expect(rejected.visible).toBeNull();
});
});
@@ -0,0 +1,170 @@
import type { MarkdownPhase } from "../types";
export type DiagramColorScheme = "light" | "dark";
export interface DiagramDimensions {
height: number;
width: number;
}
export interface RenderedDiagram extends DiagramDimensions {
source: string;
colorScheme: DiagramColorScheme;
}
export interface MermaidRenderInput {
source: string;
phase: MarkdownPhase;
colorScheme: DiagramColorScheme;
rejected: boolean;
cached: RenderedDiagram | null;
}
export interface MermaidRenderRequest {
revision: number;
source: string;
colorScheme: DiagramColorScheme;
}
export interface MermaidRenderModel {
revision: number;
source: string;
phase: MarkdownPhase;
colorScheme: DiagramColorScheme;
status: "pending" | "rendered" | "failed" | "rejected";
visible: RenderedDiagram | null;
}
export type MermaidRenderAction =
| { type: "inputChanged"; input: MermaidRenderInput }
| {
type: "rendered";
revision: number;
source: string;
colorScheme: DiagramColorScheme;
dimensions: DiagramDimensions;
}
| { type: "renderFailed"; revision: number };
function reduceRendered(
state: MermaidRenderModel,
action: Extract<MermaidRenderAction, { type: "rendered" }>,
): MermaidRenderModel {
if (action.colorScheme !== state.colorScheme) {
return state;
}
if (action.revision !== state.revision || action.source !== state.source) {
const isStreamingPrefix =
state.phase === "streaming" &&
action.revision < state.revision &&
state.source.startsWith(action.source);
const isNewerPreview =
state.visible === null || action.source.length > state.visible.source.length;
if (!isStreamingPrefix || !isNewerPreview) {
return state;
}
return {
...state,
visible: {
source: action.source,
colorScheme: action.colorScheme,
...action.dimensions,
},
};
}
return {
...state,
status: "rendered",
visible: {
source: action.source,
colorScheme: action.colorScheme,
...action.dimensions,
},
};
}
export function createMermaidRenderModel(input: MermaidRenderInput): MermaidRenderModel {
if (input.rejected) {
return {
revision: 1,
source: input.source,
phase: input.phase,
colorScheme: input.colorScheme,
status: "rejected",
visible: input.phase === "complete" ? null : input.cached,
};
}
return {
revision: 1,
source: input.source,
phase: input.phase,
colorScheme: input.colorScheme,
status: "pending",
visible: input.cached,
};
}
export function reduceMermaidRenderModel(
state: MermaidRenderModel,
action: MermaidRenderAction,
): MermaidRenderModel {
if (action.type === "rendered") {
return reduceRendered(state, action);
}
if (action.type === "renderFailed") {
if (action.revision !== state.revision) {
return state;
}
return {
...state,
status: "failed",
visible: state.phase === "complete" ? null : state.visible,
};
}
const { input } = action;
const isSameRender = input.source === state.source && input.colorScheme === state.colorScheme;
if (isSameRender) {
if (input.phase === state.phase) {
return state;
}
const finalFailure = input.phase === "complete" && state.status === "failed";
return {
...state,
phase: input.phase,
visible: finalFailure ? null : state.visible,
};
}
if (input.rejected) {
return {
revision: state.revision + 1,
source: input.source,
phase: input.phase,
colorScheme: input.colorScheme,
status: "rejected",
visible: input.phase === "complete" ? null : (input.cached ?? state.visible),
};
}
return {
revision: state.revision + 1,
source: input.source,
phase: input.phase,
colorScheme: input.colorScheme,
status: "pending",
visible: input.cached ?? state.visible,
};
}
export function getMermaidRenderRequest(state: MermaidRenderModel): MermaidRenderRequest | null {
if (state.status !== "pending") {
return null;
}
return {
revision: state.revision,
source: state.source,
colorScheme: state.colorScheme,
};
}
@@ -0,0 +1,131 @@
import mermaid from "mermaid";
import {
parseMermaidRuntimeRenderMessage,
type MermaidRuntimeMessage,
type MermaidRuntimeRenderMessage,
} from "./messages";
declare global {
interface Window {
ReactNativeWebView?: {
postMessage?: (data: string) => void;
};
__PASEO_MERMAID_RUNTIME_RECEIVE__?: (message: unknown) => void;
}
}
function sendToHost(message: MermaidRuntimeMessage): void {
window.ReactNativeWebView?.postMessage?.(JSON.stringify(message));
if (window.parent !== window) {
window.parent.postMessage(message, "*");
}
}
function initializeMermaid(colorScheme: "light" | "dark"): void {
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
suppressErrorRendering: true,
theme: colorScheme === "dark" ? "dark" : "default",
secure: [
"secure",
"securityLevel",
"startOnLoad",
"maxTextSize",
"suppressErrorRendering",
"maxEdges",
"theme",
"themeVariables",
"themeCSS",
],
flowchart: { htmlLabels: false },
class: { htmlLabels: false },
});
}
function setViewport(interactive: boolean): void {
document
.querySelector('meta[name="viewport"]')
?.setAttribute(
"content",
interactive
? "width=device-width, initial-scale=1, maximum-scale=8"
: "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no",
);
}
let latestRevision = 0;
let pendingRender: MermaidRuntimeRenderMessage | null = null;
let isRendering = false;
let isDrainScheduled = false;
async function render(message: MermaidRuntimeRenderMessage): Promise<void> {
try {
initializeMermaid(message.colorScheme);
const { svg } = await mermaid.render(`paseo-mermaid-${message.revision}`, message.source);
if (message.revision !== latestRevision) {
return;
}
setViewport(message.interactive);
const host = document.getElementById("diagram");
if (!host) {
return;
}
host.innerHTML = svg;
const rect = host.querySelector("svg")?.getBoundingClientRect();
sendToHost({
type: "rendered",
revision: message.revision,
source: message.source,
colorScheme: message.colorScheme,
height: Math.ceil(rect?.height ?? host.scrollHeight),
width: Math.ceil(rect?.width ?? host.scrollWidth),
});
} catch {
if (message.revision === latestRevision) {
sendToHost({ type: "renderError", revision: message.revision });
}
}
}
async function drainRenderQueue(): Promise<void> {
if (isRendering) {
return;
}
isRendering = true;
try {
while (pendingRender) {
const next = pendingRender;
pendingRender = null;
await render(next);
}
} finally {
isRendering = false;
}
}
function receiveRender(value: unknown): void {
const message = parseMermaidRuntimeRenderMessage(value);
if (!message) {
return;
}
latestRevision = message.revision;
pendingRender = message;
if (isRendering || isDrainScheduled) {
return;
}
isDrainScheduled = true;
window.setTimeout(() => {
isDrainScheduled = false;
void drainRenderQueue();
}, 0);
}
window.__PASEO_MERMAID_RUNTIME_RECEIVE__ = receiveRender;
window.addEventListener("message", (event) => {
if (event.source === window.parent) {
receiveRender(event.data);
}
});
sendToHost({ type: "bridgeReady" });
File diff suppressed because one or more lines are too long
@@ -0,0 +1,91 @@
import type { DiagramColorScheme, DiagramDimensions } from "../render-model";
export interface MermaidRuntimeRenderMessage {
type: "render";
revision: number;
source: string;
colorScheme: DiagramColorScheme;
interactive: boolean;
}
export type MermaidRuntimeMessage =
| { type: "bridgeReady" }
| ({
type: "rendered";
revision: number;
source: string;
colorScheme: DiagramColorScheme;
} & DiagramDimensions)
| { type: "renderError"; revision: number };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isColorScheme(value: unknown): value is DiagramColorScheme {
return value === "light" || value === "dark";
}
export function parseMermaidRuntimeRenderMessage(
value: unknown,
): MermaidRuntimeRenderMessage | null {
if (
!isRecord(value) ||
value.type !== "render" ||
typeof value.revision !== "number" ||
!Number.isInteger(value.revision) ||
typeof value.source !== "string" ||
!isColorScheme(value.colorScheme) ||
typeof value.interactive !== "boolean"
) {
return null;
}
return {
type: "render",
revision: value.revision,
source: value.source,
colorScheme: value.colorScheme,
interactive: value.interactive,
};
}
export function parseMermaidRuntimeMessage(value: unknown): MermaidRuntimeMessage | null {
if (!isRecord(value) || typeof value.type !== "string") {
return null;
}
if (value.type === "bridgeReady") {
return { type: "bridgeReady" };
}
if (
value.type === "renderError" &&
typeof value.revision === "number" &&
Number.isInteger(value.revision)
) {
return { type: "renderError", revision: value.revision };
}
if (
value.type === "rendered" &&
typeof value.revision === "number" &&
Number.isInteger(value.revision) &&
typeof value.source === "string" &&
isColorScheme(value.colorScheme) &&
typeof value.height === "number" &&
Number.isFinite(value.height) &&
typeof value.width === "number" &&
Number.isFinite(value.width)
) {
return {
type: "rendered",
revision: value.revision,
source: value.source,
colorScheme: value.colorScheme,
height: value.height,
width: value.width,
};
}
return null;
}
export function serializeMermaidRuntimeRenderMessage(message: MermaidRuntimeRenderMessage): string {
return JSON.stringify(message).replace(/<\/script/gi, "<\\/script");
}
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import type { MermaidRenderRequest } from "../render-model";
import { MermaidRuntimeRequestDriver } from "./request-driver";
function request(revision: number, source: string): MermaidRenderRequest {
return { revision, source, colorScheme: "dark" };
}
describe("MermaidRuntimeRequestDriver", () => {
it("tries queued streaming prefixes in order until the runtime renders one", () => {
const driver = new MermaidRuntimeRequestDriver();
const first = request(1, "flow");
const valid = request(2, "flowchart TD\nA --> B\n");
const transient = request(3, "flowchart TD\nA --> B\nB --");
expect(driver.update(first)).toBeNull();
expect(driver.update(valid)).toBeNull();
expect(driver.update(transient)).toBeNull();
expect(driver.ready()).toBe(valid);
expect(driver.settled(valid.revision, true)).toBe(transient);
});
it("keeps completed lines but collapses incomplete token revisions before readiness", () => {
const driver = new MermaidRuntimeRequestDriver();
const partial = request(1, "flowchart TD\nA --");
const completed = request(2, "flowchart TD\nA --> B\n");
const nextPartial = request(3, "flowchart TD\nA --> B\nB --");
const latestPartial = request(4, "flowchart TD\nA --> B\nB -->");
driver.update(partial);
driver.update(completed);
driver.update(nextPartial);
driver.update(latestPartial);
expect(driver.ready()).toBe(completed);
expect(driver.settled(completed.revision, true)).toBe(latestPartial);
});
it("collapses later updates to the newest request after the first render", () => {
const driver = new MermaidRuntimeRequestDriver();
const first = request(1, "flowchart TD\nA --> B");
const second = request(2, "flowchart TD\nA --> B\nB --> C");
const latest = request(3, "flowchart TD\nA --> B\nB --> D");
driver.update(first);
expect(driver.ready()).toBe(first);
expect(driver.settled(first.revision, true)).toBeNull();
expect(driver.update(second)).toBe(second);
expect(driver.update(latest)).toBeNull();
expect(driver.settled(second.revision, false)).toBe(latest);
});
});
@@ -0,0 +1,63 @@
import type { MermaidRenderRequest } from "../render-model";
function isCompletedLineCandidate(source: string): boolean {
return source.endsWith("\n") && source.trim().includes("\n");
}
export class MermaidRuntimeRequestDriver {
private readyState = false;
private renderedOnce = false;
private inFlight: MermaidRenderRequest | null = null;
private queued: MermaidRenderRequest[] = [];
update(request: MermaidRenderRequest | null): MermaidRenderRequest | null {
if (!request || this.hasRevision(request.revision)) {
return null;
}
if (this.renderedOnce) {
this.queued = [request];
} else {
const lastIndex = this.queued.length - 1;
const last = this.queued[lastIndex];
if (last && !isCompletedLineCandidate(last.source)) {
this.queued[lastIndex] = request;
} else {
this.queued.push(request);
}
}
return this.takeNext();
}
ready(): MermaidRenderRequest | null {
this.readyState = true;
return this.takeNext();
}
settled(revision: number, rendered: boolean): MermaidRenderRequest | null {
if (this.inFlight?.revision !== revision) {
return null;
}
this.inFlight = null;
if (rendered) {
this.renderedOnce = true;
const latest = this.queued.at(-1);
this.queued = latest ? [latest] : [];
}
return this.takeNext();
}
private hasRevision(revision: number): boolean {
return (
this.inFlight?.revision === revision ||
this.queued.some((request) => request.revision === revision)
);
}
private takeNext(): MermaidRenderRequest | null {
if (!this.readyState || this.inFlight || this.queued.length === 0) {
return null;
}
this.inFlight = this.queued.shift() ?? null;
return this.inFlight;
}
}
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { mermaidRuntimeHtml } from "./html.gen";
describe("Mermaid runtime document", () => {
it("ships a closed network policy", () => {
expect(mermaidRuntimeHtml).toContain("default-src 'none'");
expect(mermaidRuntimeHtml).toContain("connect-src 'none'");
expect(mermaidRuntimeHtml).toContain("img-src data: blob:");
expect(mermaidRuntimeHtml).toContain("font-src 'none'");
expect(mermaidRuntimeHtml).toContain("frame-src 'none'");
});
});
@@ -0,0 +1,119 @@
import { afterEach, describe, expect, it } from "vitest";
import { mermaidRuntimeHtml } from "./html.gen";
import { parseMermaidRuntimeMessage, type MermaidRuntimeMessage } from "./messages";
const mountedFrames: HTMLIFrameElement[] = [];
function waitForRuntimeMessage(
frame: HTMLIFrameElement,
predicate: (message: MermaidRuntimeMessage) => boolean,
): Promise<MermaidRuntimeMessage> {
return new Promise((resolve, reject) => {
const timeout = window.setTimeout(() => {
window.removeEventListener("message", receive);
reject(new Error("Timed out waiting for Mermaid runtime"));
}, 10_000);
function receive(event: MessageEvent): void {
if (event.source !== frame.contentWindow) {
return;
}
const message = parseMermaidRuntimeMessage(event.data);
if (!message || !predicate(message)) {
return;
}
window.clearTimeout(timeout);
window.removeEventListener("message", receive);
resolve(message);
}
window.addEventListener("message", receive);
});
}
async function mountRuntime(): Promise<HTMLIFrameElement> {
const frame = document.createElement("iframe");
frame.sandbox.add("allow-scripts");
const ready = waitForRuntimeMessage(frame, (message) => message.type === "bridgeReady");
frame.srcdoc = mermaidRuntimeHtml;
document.body.append(frame);
mountedFrames.push(frame);
await ready;
return frame;
}
function render(
frame: HTMLIFrameElement,
input: { revision: number; source: string },
): Promise<MermaidRuntimeMessage> {
const response = waitForRuntimeMessage(
frame,
(message) =>
message.type !== "bridgeReady" &&
"revision" in message &&
message.revision === input.revision,
);
frame.contentWindow?.postMessage(
{
type: "render",
revision: input.revision,
source: input.source,
colorScheme: "dark",
interactive: false,
},
"*",
);
return response;
}
afterEach(() => {
for (const frame of mountedFrames.splice(0)) {
frame.remove();
}
});
describe("Mermaid sandbox runtime", () => {
it("renders successive valid streaming prefixes and reports an invalid prefix", async () => {
const frame = await mountRuntime();
const firstSource = "flowchart TD\nA --> B";
const secondSource = `${firstSource}\nB --> C`;
const first = await render(frame, { revision: 1, source: firstSource });
const invalid = await render(frame, { revision: 2, source: "not a mermaid diagram" });
const second = await render(frame, { revision: 3, source: secondSource });
expect(first).toMatchObject({ type: "rendered", revision: 1, source: firstSource });
expect(invalid).toEqual({ type: "renderError", revision: 2 });
expect(second).toMatchObject({ type: "rendered", revision: 3, source: secondSource });
});
it("coalesces queued input and never reports an obsolete result", async () => {
const frame = await mountRuntime();
const obsoleteSource = `flowchart TD\n${Array.from({ length: 250 }, (_, index) => `A${index} --> A${index + 1}`).join("\n")}`;
const currentSource = "flowchart TD\nCurrent --> Result";
const obsoleteResponses: MermaidRuntimeMessage[] = [];
function collect(event: MessageEvent): void {
if (event.source !== frame.contentWindow) {
return;
}
const message = parseMermaidRuntimeMessage(event.data);
if (message && message.type !== "bridgeReady" && message.revision === 10) {
obsoleteResponses.push(message);
}
}
window.addEventListener("message", collect);
frame.contentWindow?.postMessage(
{
type: "render",
revision: 10,
source: obsoleteSource,
colorScheme: "dark",
interactive: false,
},
"*",
);
const current = await render(frame, { revision: 11, source: currentSource });
window.removeEventListener("message", collect);
expect(current).toMatchObject({ type: "rendered", revision: 11, source: currentSource });
expect(obsoleteResponses).toEqual([]);
});
});
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { containsUnsafeMermaidSource } from "./source-policy";
describe("containsUnsafeMermaidSource", () => {
it("rejects resource-bearing constructs", () => {
expect(containsUnsafeMermaidSource('flowchart TD\n A@{ img: "https://x/y.png" }')).toBe(true);
expect(containsUnsafeMermaidSource("flowchart TD\n A@{ icon: 'pack:name' }")).toBe(true);
expect(
containsUnsafeMermaidSource('%%{init: {"themeCSS": "a { color: red }"}}%%\ngraph TD'),
).toBe(true);
expect(containsUnsafeMermaidSource("graph TD\n A[url(http://x)]")).toBe(true);
expect(containsUnsafeMermaidSource("graph TD\n A[@import 'x']")).toBe(true);
expect(containsUnsafeMermaidSource('graph TD\n A["<img src=x>"]')).toBe(true);
expect(containsUnsafeMermaidSource('graph TD\n A["<i class=x>styled</i>"]')).toBe(true);
expect(containsUnsafeMermaidSource('graph TD\n A["&#60;img src=x&#62;"]')).toBe(true);
expect(containsUnsafeMermaidSource('graph TD\n A["</b>"]')).toBe(true);
});
it("rejects all shape-data constructs, including yaml key evasions", () => {
expect(containsUnsafeMermaidSource('flowchart TD\n A@{ "img": "https://x/y.png" }')).toBe(
true,
);
expect(containsUnsafeMermaidSource("flowchart TD\n A@{ 'img': 'https://x/y.png' }")).toBe(
true,
);
expect(
containsUnsafeMermaidSource('flowchart TD\n A@{ "\\u0069mg": "https://x/y.png" }'),
).toBe(true);
expect(
containsUnsafeMermaidSource('flowchart TD\n A@{ "\\u{69}mg": "https://x/y.png" }'),
).toBe(true);
expect(containsUnsafeMermaidSource('flowchart TD\n A@{ "\\x69mg": "https://x/y.png" }')).toBe(
true,
);
expect(
containsUnsafeMermaidSource('flowchart TD\n A@{ "\\U00000069mg": "https://attacker/x" }'),
).toBe(true);
expect(containsUnsafeMermaidSource('flowchart TD\n A@{ "icon": "pack:name" }')).toBe(true);
expect(
containsUnsafeMermaidSource(
'flowchart TD\n A@{ ? img # comment\n : "https://attacker/x" }',
),
).toBe(true);
expect(
containsUnsafeMermaidSource(
'flowchart TD\n A@{ dummy: &k img\n ? *k # comment\n : "https://attacker/x" }',
),
).toBe(true);
});
it("fails closed for malformed or out-of-range escapes without throwing", () => {
expect(() =>
containsUnsafeMermaidSource('graph TD\n A["\\u{110000} disguised"]'),
).not.toThrow();
expect(containsUnsafeMermaidSource('graph TD\n A["\\u{110000} disguised"]')).toBe(true);
expect(() =>
containsUnsafeMermaidSource('graph TD\n A["\\u{FFFFFF} disguised"]'),
).not.toThrow();
expect(containsUnsafeMermaidSource('graph TD\n A["\\u{FFFFFF} disguised"]')).toBe(true);
});
it("allows ordinary diagrams including formatting-only labels", () => {
expect(containsUnsafeMermaidSource("flowchart TD\n A[Start] --> B{Choice}")).toBe(false);
expect(containsUnsafeMermaidSource('graph TD\n A["line one<br>line two"]')).toBe(false);
expect(containsUnsafeMermaidSource('graph TD\n A["line one<br/>line two"]')).toBe(false);
expect(containsUnsafeMermaidSource('graph TD\n A["<i>formatted</i>"]')).toBe(false);
expect(containsUnsafeMermaidSource("sequenceDiagram\n Alice->>Bob: a < b and x > y")).toBe(
false,
);
expect(containsUnsafeMermaidSource("flowchart TD\n A@{ shape: rect }")).toBe(true);
});
});
@@ -0,0 +1,52 @@
// Mermaid can fetch external resources while *rendering* — image shapes
// (`A@{ img: "url" }`) construct an Image and await decode before any output
// sanitization runs, and CSS can pull url()/@import — so a prompt-injected
// diagram could exfiltrate data in a request URL. securityLevel "strict" does
// not prevent this (mermaid-js/mermaid#7645). Mermaid 11.16 parses the shape
// metadata object with yaml.JSON_SCHEMA semantics, including aliases and
// explicit keys; matching those keys safely would require a real parser. We
// therefore reject any `@{ ... }` shape-data construct up front and fall back
// to the source code block. Formatting-only `<br>` and `<i>` tags are common
// in generated labels and carry no resource-bearing attributes; all other
// tags (and entity-encoded text that could smuggle one) are rejected.
const UNSAFE_MERMAID_SOURCE =
/@\s*\{|url\s*\(|@import\b|themeCSS|&#|<(?!\/?(?:br|i)\s*\/?>)[a-z!/]/i;
// Mermaid labels can contain escaped text. Decode the escape forms we care
// about before running the denylist so disguised HTML still gets caught.
// Invalid escape sequences fail closed and fall back to the source block.
function normalizeMermaidSource(code: string): string | null {
try {
return code
.replace(/\\u\{([0-9a-fA-F]{1,6})\}/g, (_, hex: string) =>
decodeCodePointEscape(Number.parseInt(hex, 16)),
)
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) =>
String.fromCharCode(Number.parseInt(hex, 16)),
)
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex: string) =>
String.fromCharCode(Number.parseInt(hex, 16)),
)
.replace(/["'`\\]/g, "");
} catch {
return null;
}
}
function decodeCodePointEscape(value: number): string {
if (!Number.isInteger(value) || value < 0 || value > 0x10ffff) {
throw new RangeError("Invalid Unicode code point");
}
return String.fromCodePoint(value);
}
export function containsUnsafeMermaidSource(code: string): boolean {
if (UNSAFE_MERMAID_SOURCE.test(code)) {
return true;
}
const normalized = normalizeMermaidSource(code);
if (normalized === null) {
return true;
}
return UNSAFE_MERMAID_SOURCE.test(normalized);
}
@@ -0,0 +1,96 @@
import { useCallback, useEffect, useMemo, useReducer } from "react";
import {
createMermaidRenderModel,
getMermaidRenderRequest,
reduceMermaidRenderModel,
type DiagramColorScheme,
type DiagramDimensions,
type RenderedDiagram,
} from "./render-model";
import { containsUnsafeMermaidSource } from "./source-policy";
import type { MarkdownPhase } from "../types";
const renderCache = new Map<string, RenderedDiagram>();
const RENDER_CACHE_LIMIT = 50;
function cacheKey(source: string, colorScheme: DiagramColorScheme): string {
return `${colorScheme}\u0000${source}`;
}
function readCachedRender(source: string, colorScheme: DiagramColorScheme): RenderedDiagram | null {
return renderCache.get(cacheKey(source, colorScheme)) ?? null;
}
function cacheRender(rendered: RenderedDiagram): void {
if (renderCache.size >= RENDER_CACHE_LIMIT) {
const oldest = renderCache.keys().next().value;
if (oldest !== undefined) {
renderCache.delete(oldest);
}
}
renderCache.set(cacheKey(rendered.source, rendered.colorScheme), rendered);
}
export function useMermaidRenderModel({
source,
phase,
colorScheme,
}: {
source: string;
phase: MarkdownPhase;
colorScheme: DiagramColorScheme;
}) {
const renderInput = useMemo(
() => ({
source,
phase,
colorScheme,
rejected: containsUnsafeMermaidSource(source),
cached: readCachedRender(source, colorScheme),
}),
[colorScheme, phase, source],
);
const [state, dispatch] = useReducer(
reduceMermaidRenderModel,
renderInput,
createMermaidRenderModel,
);
useEffect(() => {
dispatch({ type: "inputChanged", input: renderInput });
}, [renderInput]);
const rendered = useCallback(
(response: {
revision: number;
source: string;
colorScheme: DiagramColorScheme;
dimensions: DiagramDimensions;
}) => {
const cached: RenderedDiagram = {
source: response.source,
colorScheme: response.colorScheme,
...response.dimensions,
};
cacheRender(cached);
dispatch({
type: "rendered",
revision: response.revision,
source: response.source,
colorScheme: response.colorScheme,
dimensions: response.dimensions,
});
},
[],
);
const renderFailed = useCallback((revision: number) => {
dispatch({ type: "renderFailed", revision });
}, []);
return {
state,
request: getMermaidRenderRequest(state),
rendered,
renderFailed,
};
}
@@ -0,0 +1,10 @@
import type { TextStyle } from "react-native";
export type MarkdownPhase = "streaming" | "complete";
export interface MarkdownFenceRendererProps {
code: string;
phase: MarkdownPhase;
inheritedStyles: TextStyle;
textStyle: TextStyle;
}
@@ -0,0 +1,21 @@
import { Children, cloneElement, isValidElement, type ReactNode } from "react";
import type { StyleProp, TextStyle } from "react-native";
export function colorMarkdownLinkChildren(
children: ReactNode,
color: TextStyle["color"],
): ReactNode {
return Children.map(children, (child) => {
if (!isValidElement<{ style?: StyleProp<TextStyle> }>(child)) return child;
return cloneElement(child, { style: [child.props.style, { color }] });
});
}
const MARKDOWN_LINK_HOVER_STYLE: TextStyle = { textDecorationLine: "underline" };
export function markdownLinkTextStyle(
style: StyleProp<TextStyle>,
hovered: boolean,
): StyleProp<TextStyle> {
return [style, hovered && MARKDOWN_LINK_HOVER_STYLE];
}
@@ -0,0 +1,41 @@
import { useMemo, useState, type ReactNode } from "react";
import { Pressable, Text, type StyleProp, type TextStyle } from "react-native";
import { useStableEvent } from "@/hooks/use-stable-event";
import { markdownLinkTextStyle } from "./link-children";
interface MarkdownLinkTextProps {
style: StyleProp<TextStyle>;
dataSet?: Record<string, string>;
onPress(): void;
onHoverIn?(): void;
children?: ReactNode;
}
export function MarkdownLinkText({
style,
dataSet,
onPress,
onHoverIn,
children,
}: MarkdownLinkTextProps) {
const [hovered, setHovered] = useState(false);
const handleHoverIn = useStableEvent(() => {
setHovered(true);
onHoverIn?.();
});
const handleHoverOut = useStableEvent(() => setHovered(false));
const textStyle = useMemo(() => markdownLinkTextStyle(style, hovered), [hovered, style]);
return (
<Pressable
accessibilityRole="link"
onPress={onPress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
>
<Text dataSet={dataSet} style={textStyle}>
{children}
</Text>
</Pressable>
);
}
@@ -1,5 +1,48 @@
import { describe, expect, it } from "vitest";
/**
* @vitest-environment jsdom
*/
import * as React from "react";
import { createElement, type ReactNode } from "react";
import { fireEvent, render } from "@testing-library/react";
import { Text, type StyleProp, type TextStyle } from "react-native";
import { describe, expect, it, vi } from "vitest";
import { resolveInlineImageSize } from "./inline-image-size";
import { colorMarkdownLinkChildren } from "./link-children";
import { MarkdownLinkText } from "./link-text";
vi.stubGlobal("React", React);
vi.mock("react-native", () => ({
Pressable: ({
accessibilityRole,
children,
onHoverIn,
onHoverOut,
onPress,
}: {
accessibilityRole?: string;
children?: ReactNode;
onHoverIn?(): void;
onHoverOut?(): void;
onPress?(): void;
}) =>
createElement(
"div",
{
role: accessibilityRole,
onClick: onPress,
onMouseEnter: onHoverIn,
onMouseLeave: onHoverOut,
},
children,
),
Text: ({ children, style }: { children?: ReactNode; style?: StyleProp<TextStyle> }) =>
createElement("span", { style: flattenStyle(style) }, children),
}));
function flattenStyle(style: StyleProp<TextStyle>): TextStyle {
return Object.assign({}, ...(Array.isArray(style) ? style.filter(Boolean) : [style]));
}
describe("resolveInlineImageSize", () => {
it("respects a one-sided explicit width using natural aspect ratio", () => {
@@ -27,3 +70,30 @@ describe("resolveInlineImageSize", () => {
});
});
});
describe("shared Markdown links", () => {
it("renders accent text and underlines it while hovered", () => {
const onPress = vi.fn();
const children = colorMarkdownLinkChildren(
createElement(Text, { style: { color: "white" } }, "Paseo"),
"rgb(0, 122, 255)",
);
const view = render(
createElement(MarkdownLinkText, { style: { color: "rgb(0, 122, 255)" }, onPress }, children),
);
const link = view.getByRole("link");
const linkText = link.firstElementChild as HTMLElement;
expect((view.getByText("Paseo") as HTMLElement).style.color).toBe("rgb(0, 122, 255)");
expect(linkText.style.textDecorationLine).toBe("");
fireEvent.mouseEnter(link);
expect(linkText.style.textDecorationLine).toBe("underline");
fireEvent.mouseLeave(link);
expect(linkText.style.textDecorationLine).toBe("");
fireEvent.click(link);
expect(onPress).toHaveBeenCalledOnce();
});
});

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