Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andMatt Cowger acb96c02cc fix(opencode): keep v2 turns running past five minutes
OpenCode answers session.wait only when the session goes idle, and the
helper client inherited fetch's five-minute header deadline, so every
longer turn failed with UND_ERR_HEADERS_TIMEOUT while OpenCode kept
working. The helper client now uses a dispatcher without a header
deadline; its requests end when the helper exits or the caller aborts.

Co-authored-by: Matt Cowger <matt@cowger.us>
2026-09-29 09:22:54 +02:00
paseo-bot[bot] b9ac1d4982 fix(app): add a Back to plugins link to plugin settings screens on desktop (#5620)
On the wide settings layout, a plugin settings screen showed only its title
and the plugin's content, so getting back to the Plugins page meant finding
Plugins in the sidebar again. The compact layout already had a Back header
and project settings already had a desktop "Back to projects" link. Plugin
settings screens now render a "Back to plugins" link on desktop the same
way, including when the screen is offline or unavailable.

Closes #5518

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 09:10:21 +02:00
paseo-bot[bot] d91171954b fix(opencode): keep v2 edit text in completed edit details (#5609)
OpenCode v2's edit tool takes `path` instead of `filePath`, so the
OpenCode edit schema missed it and the shared fallback dropped the
camelCase oldString/newString. Completed edits reached the timeline as
{type: "edit", filePath} with nothing to diff.

Closes #5607

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 08:01:59 +02:00
paseo-bot[bot]andDaye f232d23e9a fix(client): let SDK callers choose what a send does to an active turn (#5605)
PaseoAgentSendOptions was a hand-copied subset of the daemon client's
SendMessageOptions and dropped activeTurnBehavior, so
agents.ref(id).send(text, { activeTurnBehavior: "steer" }) and run() failed
to typecheck even though the value is forwarded to the daemon. The public
type is now the options type send() passes through, so the two cannot
drift again.

Closes #4555

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Daye <2077152374@qq.com>
2026-09-29 06:53:14 +02:00
paseo-bot[bot] 68e722ec2e fix(app): request a chat outline only for a timeline the daemon has served (#5602)
A new agent's draft pane rendered the chat outline with its local tab or
draft id, so the web app asked the daemon for that id's prompt index before
the agent existed and the daemon logged Agent not found. The outline now
waits until the viewed timeline has an epoch, which only a daemon-served
agent timeline has.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 06:16:30 +02:00
paseo-bot[bot] 3b41183606 fix(app): start the context meter ring at twelve o'clock on web (#5598)
The ring's quarter-turn lived in a Unistyles style on the react-native-svg
root. On web, Unistyles only turns styles into classes for the React Native
components its Babel plugin rewrites, so the Svg received an empty style and
the arc started at three o'clock. The rotation now lives on the progress
circle as an SVG transform, which renders the same on web and native.

Closes #5596

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 04:57:36 +02:00
paseo-bot[bot]andIvan Malison 690f5298cb fix(app): keep draft composers off the agent command list before a project and model are chosen (#5594)
A draft composer passed no command config when it was missing a project
or a provider, which is what a running agent's composer passes. The `/`
menu then listed commands by the draft key, got "Agent not found", and
showed /clear and /exit. Drafts now always report a command target
(ready, needs-project, needs-provider), and an incomplete draft asks for
the missing choice instead of querying.

Closes #5516

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
2026-09-29 04:08:31 +02:00
paseo-bot[bot] 68938b668e fix(skills): make the paseo-plugin composer pill example match the plugin SDK (#5591)
The example passed title, Component, and onPress straight to
addComposerPill and treated its return value as a remover function, so it
failed to typecheck against @getpaseo/plugin. It also followed agents with
agents.subscribe, a local listener that never delivers agents that exist
before the plugin loads. The example now uses the button descriptor and an
owned list subscription, like the plugin reference and local-plugin
example, and a test typechecks it against the SDK.

Closes #5517

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 02:51:54 +02:00
paseo-bot[bot] 560e478ef5 Wait for the connection before starting a file upload (#5585)
An upload started while the client was reconnecting, such as right after
returning from the Android file picker, queued its request but sent the
file frames at once. The frames were dropped and the upload failed with
"Connection changed during file upload". The upload now waits on the
same connection queue as other requests before it sends anything.

Closes #5512

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 01:05:46 +02:00
Mohamed Boudra 4965af219f docs(changelog): add 0.10.1 release notes 2026-09-29 00:03:24 +02:00
Mohamed Boudra 940dbfd242 fix(opencode): clear unsupported thinking when changing models (#5587)
Resolve retained variants against the destination model before applying a model change. Publish the effective thinking choice and refresh the provider resume handle immediately so a reload cannot restore the old selection.
2026-09-28 22:32:35 +01:00
Qi 4340607093 feat(claude): add Sonnet 5.5 with its model capabilities (#5583)
Claude Code 2.1.284 introduces Sonnet 5.5 with a native 1M context
window, medium default effort, and adaptive thinking that cannot be
turned off, so it takes the same shape as Opus 5.5 in the manifest.

Sonnet 5.5 rejects `thinking: {"type": "disabled"}` with a 400, so its
entry omits supportsThinkingDisabled and its thinking options have no
off. It also takes the Sonnet family tagline, which moves Sonnet 5 to
Previous release.

The catalog gates the entry on Claude Code 2.1.284, the version that
recognizes the model, so older installs keep their current catalog.
The existing major-minor runtime matching already resolves dashed
gateway IDs such as us.anthropic.claude-sonnet-5-5-20260928-v1:0 to
the new entry without collapsing them onto Sonnet 5.
2026-09-28 22:15:24 +01:00
paseo-bot[bot] 849a876bce Keep completed plugin-provider sessions error-free across a plugin reload (#5579)
Removing a plugin provider closed its runtime and published a runtime
failure for every open session, so an idle session whose turn had already
completed gained a "Provider connection closed" error. Only a turn that
is still in flight is interrupted now; an idle session goes stale and its
next prompt reopens it from persistence.

Closes #5535

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-28 22:55:07 +02:00
paseo-bot[bot] f4ba16a0b3 fix(app): keep line breaks in streamed replies when a chunk ends in indentation (#5577)
While a reply streams, presentation reparses only the growing last Markdown
block: the cached block text plus the new chunk. splitMarkdownBlocks drops a
trailing whitespace-only line, and only trailing "\n"s were added back, so a
reply that paused on "]\n   " cached the block as "]". The next chunk " B -->"
then continued the previous line, joining two lines of a code block. Codex
streams a line break and the next line's indent as separate deltas, so its
Mermaid diagrams hit this and failed to render. History reparses the whole
text, which is why a reload showed the reply correctly.

Keep every trailing blank line the split drops on the last block.

Closes #5456

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-28 22:09:16 +02:00
paseo-bot[bot] 9d010211a2 Archive a custom Codex provider's session when its agent is archived (#5572)
A provider that extends Codex runs through the registry's client wrapper,
which forwarded session listing and import but not native archive or
unarchive. Archiving such an agent left its Codex thread unarchived, so
the session stayed in Import session and could be imported again. The
wrapper now forwards both, the same as stock Codex.

Closes #5454

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-28 21:14:58 +02:00
Mohamed Boudra 8b5201fedf Remove the no-card line from the Hub access FAQ (#5575) 2026-09-28 18:59:24 +01:00
paseo-bot[bot]andMohamed Boudra 05874e289a Keep relative timestamps current in agent, schedule, and import-session rows (#5340)
* Keep relative timestamps current in agent, schedule, and import-session rows

Agent list rows, schedule rows, and import-session rows formatted their
"5m ago" labels once at render, so an idle row kept its first label until
something else re-rendered it. They now use useTimeAgo, which rides the
same shared ticker the sidebar and desktop tabs already use.

Closes #4625

* Say "just now" for the whole first minute so fresh labels stay honest

The shared ticker wakes once a minute, so a seconds label could only go
stale. The prose and compact formatters now share one age description,
and the schedule spec anchors its clock to the schedule's creation time.

* Run every cleanup in the schedule relative-time spec and name its steps

* Keep relative labels current with focused subscriptions

* Update usage card after relative-time hook rename

* Anchor import-session age spec to fixture time

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-28 18:00:54 +01:00
Mohamed Boudra d0a30ed4da feat(website): Hub plan cards in USD with one Get started button (#5555)
* feat(website): use one Get started button on Hub's Free and Pro cards

* feat(website): show Hub plans in USD with price-only cards
2026-09-28 17:48:47 +01:00
Mohamed Boudra cf4509631b Show Windows file links relative to their workspace (#1987)
* fix(app): show Windows file links relative to workspace

Tooltip formatting only handled matching POSIX separators, so Windows paths could remain absolute. Reuse the workspace file-path resolver for normalized relative paths.

* fix(app): preserve workspace-root link tooltips

Workspace file resolution deliberately rejects the root as an openable file, so tooltip formatting preserves its display-only dot form separately.

* fix(app): share workspace path rules for file link tooltips
2026-09-28 17:40:48 +01:00
b85b44aea1 Show a host's new version after its daemon restarts (#5351)
* Show a host's new version after its daemon restarts

The app wrote a host's server info into the session store only when a
client was first mounted, and a reconnect reuses the same client, so
after a daemon upgrade or restart the host page and version checks kept
the old version until the app restarted.

The host runtime now projects the client's current server info into the
session store on every connection change, keeping the last known value
while disconnected. The React mount effect that duplicated this write is
removed.

Closes #5236

Co-authored-by: Leonid <127580858+bashrusakh@users.noreply.github.com>

* Keep a caller-owned test daemon home across startup retries

A failed start removed the home even with cleanup disabled, so a retry on a
taken port came back with a new serverId. The host restart spec now drives a
restartable host daemon helper that owns its port, home, and teardown.

* Remove only the test daemon dirs a failed start created

A failed attempt now removes the temp home and static dirs it created and
leaves caller-supplied ones, so cleanup: false no longer leaks per retry.

* Use function declarations in the test daemon helpers

* test(app): cover host versions across daemon disconnect and reconnect

The host settings badge retains its last known version during a transport failure; the help menu should show the replacement version after reconnect without changing another host.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Leonid <127580858+bashrusakh@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-28 17:40:41 +01:00
paseo-bot[bot]andMohamed Boudra 7bb7d4ed08 Open a subagent in the split pane it was opened from (#5451)
* fix(app): open a subagent in the pane it was opened from

Opening a subagent from an agent's composer placed the new tab in the
focused pane. Clicking the subagent row does not focus its pane, so in a
split view the subagent landed in whichever pane last had focus.

Place an implicit main-location open that has a parent tab in that tab's
pane.

Refs #5433

* test(app): cover subagent split-pane placement in browser

* test(app): use browser repro for subagent pane placement

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-28 17:27:37 +01:00
paseo-ai[bot] dd5df9ec61 fix: update lockfile signatures and Nix hash [skip ci] 2026-09-28 16:04:06 +00:00
paseo-bot[bot]andMohamed Boudra 8de52a3c99 Report a background send_agent_prompt's started turn as running (#5386)
* Report a background send_agent_prompt's started turn as running

The background branch read the target's status right after dispatch,
before the provider acknowledged the turn, so an idle target read as
idle. Wait for the run to start first, as create_agent and the app's
send path already do.

* Check the caller still gets the finish notification after a background send

* Acknowledge the held turn start explicitly in the send_agent_prompt test

* Cover immediate completion after background turn start

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-28 16:54:22 +01:00
paseo-bot[bot] dffde6ae8b fix(codex): read custom prompts from the provider's own CODEX_HOME (#5450)
A provider that extends codex and sets CODEX_HOME in its env runs Codex
against that home, but the daemon listed, expanded, and fell back to
skills from its own CODEX_HOME or ~/.codex. The / menu offered another
home's prompts and never the provider's own, and invoking one of the
provider's prompts sent the raw slash text to Codex.

Resolve the Codex home from the same environment the app-server is
launched with, and give it to the session.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-28 16:49:02 +01:00
paseo-bot[bot]andMohamed Boudra a43c8d888f Show the Cursor icon for terminal profiles that run cursor-agent (#5379)
* Show the Cursor icon for terminal profiles that run cursor-agent

The Cursor CLI binary is cursor-agent, so a profile created in settings
with that command matched no known icon and fell back to the generic
terminal icon. Map cursor-agent to the cursor icon while keeping
command matching exact.

Closes #4702

* Verify Cursor terminal profile icons in real browser

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-28 16:48:20 +01:00
Mohamed Boudra 792715e762 feat(usage): usage sources as built-in plugins, following the session's account (#5465)
* feat(server): run internal plugins in-process through the plugin runtime

* fix(server): harden internal plugin startup and boundary checks

* feat(usage): add plugin usage sources and Codex discovery

* fix(usage): share SDK helpers and bound source cache

* feat(usage): migrate usage fetchers to plugin sources

Serve the legacy usage RPC from discovered source reports so older apps retain the same host usage list while new clients use the source registry. Keep credential reads and normalization inside each source.

* refactor(usage): shape sources as fetch functions

Keep the plugin usage API limited to stable report and presentation helpers. Source-local parsers and timeout handling preserve credential and response parity while the cases move alongside each source.

* feat(usage): resolve reports from active session accounts

Resolve source inputs at fetch time so model and credential changes select the account each session spends. Keep the legacy discovered-account RPC for existing clients. Sync OpenCode model events outside foreground turns so usage follows model switches.

* fix usage source discovery and session report parity

* test adapter usage references through daemon RPC

* name usage reference contract across server

* refactor(usage): own usage RPCs and validate session auth

* feat(omp): resolve session usage from active OAuth credential

* fix(omp): match usage source input and valid credential fallback

* test(omp): use upstream credential database schema

* test(omp): verify live session usage references

* fix(usage): declare YAML parser and consolidate credential validation

* feat(app): show usage sources in the composer and a Usage screen

Replace the provider-matched usage in the context ring tooltip with a
composer pill driven by agent.get_usage_report, add a sidebar Usage
screen over usage.list_reports grouped by host, and move the host
settings Usage page to usage.list_reports. Hosts without
features.usageSources show an update message. Source icons come from
the report's catalog SVG. The app no longer calls provider.usage.list.

* fix(usage): declare the Paseo requirement on internal usage-source plugins

A manifest without requirements.paseo targets Paseo before 0.8, so the daemon
refused to start eight of the nine usage sources.

* fix(app): keep host connection statuses live under the React Compiler

The hook read a store version only as a memo trigger (void version); the
compiler dropped it, so pages that mounted before a host came online kept
showing it as connecting.

* fix(app): place the context ring slot before the usage pill

The ring reserves its slot before it has data. With the pill first, the empty
slot sat between the pill and the mic; now it falls in the flexible space.

* refactor(app): extract the settings page layout into PageLayout

Desktop puts the title in the page above a centered column; compact moves it
into a back header. Settings renders the same pixels through it.

* feat(app): lay out the Usage screen like a settings page

The title moves from the header bar into the page on desktop; compact gets the
settings back header, which returns home when the screen was opened by URL.

* refactor(app): drop the unused actions slot from PageLayout

* test(plugin): classify the usage source SDK entry

* fix(usage): preserve platform test contracts

* fix(settings): preserve page title test contract

* feat(usage): identify and cache reports by account ID

Keep report identity stable across input routes and token rotation, and make selective refresh operate on known account reports.

* feat(app): read usage reports through report IDs

Share report data between agent pills and host views while preserving the existing popover interaction for the next UI phase.

* fix(usage): keep account identity aligned with credentials

Read labels from local token claims and use one credential lookup for usage fetches and account identification. Bound cached profile identities across token rotation.

* fix(usage): isolate failure IDs and prune expired reports

Failure reports live outside the account-key namespace, and cache writes discard entries past the five-minute TTL.

* refactor(app): share the compact time-ago prose formatter

Move the tab tooltip's "3m" -> "3m ago" wording into utils/time so the
usage card can reuse it.

* feat(usage): show when each report was fetched

The usage card reads fetchedAt as "Updated 3m ago": a tooltip on the
card header where hover exists (web, desktop), printed in the card
footer on native and compact. Shared by the pill popover and the Usage
screen.

* feat(usage): open the pill popover from cache and refresh on demand

Opening the popover no longer forces a vendor fetch; it shows the cached
report. A Refresh control force-refreshes only this report
(usage.list_reports with its single ID and forceRefresh), shows
Refreshing... while it runs, keeps the previous report visible, and says
"Unable to refresh usage" if the request fails.

* feat(app): let ToolbarButton show a tooltip other than its label

An icon action can now say a status in its tooltip ("Updated 3m ago")
while keeping its label for accessibility.

* feat(usage): refresh a report from its card header

Refresh is an icon button at the right edge of every usage card header,
in the pill popover and on the Usage screen. It force-refreshes only that
report, writes the result to the report's cache and its host list, shows
a spinner and disables itself while pending, and prints "Unable to
refresh usage" in the card on failure. Its tooltip carries the
freshness; without hover the freshness stays in the card footer. The
status indicator sits just left of the button, and the popover's
separate refresh row is gone.

* fix(server): include internal plugin assets in built output

* feat(server): built-in plugins in /plugins (#5544)

* feat(server): load compiled built-in Codex plugin

* test(server): guard built-in plugin loading and installs

* test(server): verify built-in plugins from dist in CI

* refactor(server): migrate remaining usage sources to built-in plugins

* test(server): guard all packaged usage source registrations

* docs: describe usage validation across plugin hosts

* test(ci): include built-in plugins in routing contract

* test(server): import moved Codex usage source

* fix(server): retain ACP updates received with session creation
2026-09-28 16:36:34 +01:00
paseo-bot[bot] 827178df90 Keep a custom Codex provider and Paseo tools after rewinding
Rewound Codex forks receive the same provider and runtime MCP config as thread start and resume. Covers paginated and legacy rewind paths.\n\nCloses #4542\nCloses #3205\n\nCo-authored-by: zbaibg <zbai29@wisc.edu>
2026-09-28 16:29:25 +01:00
Mohamed Boudra c54f20e53e feat(website): show Hub's Free and Pro plans, and drop trial wording (#5138)
Hosted Hub replaces its automatic trial with a real Free plan, so the
pricing section becomes three columns: Self-hosted, Free, and Pro. Both
hosted columns render entirely from `GET /api/billing/plans` — name,
price, the `included` figures, and the catalog's feature prose — because
those figures are derived from the entitlement template the instance
enforces, so nothing on this page can drift from the limit a customer
actually gets.

The parser requires both slugs and the new `included` object and throws
on anything else. The page already degrades to "temporarily unavailable"
when the loader throws, so a strict boundary costs a fallback we have
rather than a wrong number we would ship. This is also why the change
must not merge before the Hub side deploys: against today's API the
section renders that fallback.

Public docs follow the two Hub changes: Free replaces the trial and
points at /hub for the figures Stripe metadata now owns; sign-in lands on
Home with its four-step checklist; `hub.execute` gets the grant command
it needs; and trigger authoring documents that the agent is validated
against the target daemon at save, which needs that daemon connected and
accepts Claude, Codex, and OpenCode only.
2026-09-28 15:51:12 +01:00
paseo-ai[bot] c7e7e52b0c fix: update lockfile signatures and Nix hash [skip ci] 2026-09-28 12:37:45 +00:00
Mohamed Boudra c481ecf3e1 chore(release): cut 0.10.0 2026-09-28 14:27:01 +02:00
Mohamed Boudra dfc9add774 docs: collapse 0.10.0 changelog for stable 2026-09-28 14:05:14 +02:00
Grisha SzepandMohamed Boudra da48803a43 fix(opencode): restore complete session environments on connection (#5526)
* fix(opencode): restore complete session environments on connection

* fix(opencode): use the shared provider environment policy

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-28 12:55:21 +01:00
Mohamed Boudra 7f5d32cdd5 website: add Paseo vs Orca alternative page (#5537) 2026-09-28 12:05:17 +01:00
Mohamed Boudra dec2d861ec Add a Philosophy section to the homepage (#5538)
* feat(website): add Philosophy section to homepage

* feat(website): reshape Philosophy section to the homepage's FeatureSection pattern
2026-09-28 12:04:15 +01:00
paseo-ai[bot] 30178c4f58 fix: update lockfile signatures and Nix hash [skip ci] 2026-09-27 12:01:37 +00:00
Mohamed Boudra 52d345db7f chore(release): cut 0.10.0-beta.1 2026-09-27 13:51:11 +02:00
Mohamed Boudra a50a476009 docs: add 0.10.0-beta.1 changelog 2026-09-27 13:48:06 +02:00
Mohamed Boudra 6ca001c3e4 chore: normalize package-lock ordering for release 2026-09-27 13:48:04 +02:00
Mohamed Boudra 78a0f093eb fix(opencode): recover v2 chats after helper exit (#5488)
* fix(opencode): reconnect v2 sessions after helper exits

A helper exit aborted the session lifetime, so every later prompt failed even though OpenCode can serve the same native session from another helper. Keep the session open, fail the active turn, and acquire and configure the current helper on the next OpenCode operation.

* fix(opencode): forget exited v2 helper generation

A dead shared helper could remain selected as the current generation. Clear that selection at exit so the first lazy reconnect acquires a live helper.
2026-09-27 13:25:49 +02:00
Mohamed Boudra d7b7016cc9 feat(settings): polish navigation and controls (#5459)
* feat(settings): polish navigation and controls

* test(settings): update navigation assertions
2026-09-26 20:26:04 +02:00
Mohamed Boudra e1c769c018 Make password-protected daemons work across local and remote connections (#5393)
* feat(auth): authenticate daemon clients through hello

Give local CLI and desktop clients a per-run credential while remote clients send the host password inside the protocol. Keep relay passwordless admission for this mobile release so existing saved hosts keep working until the follow-up enforcement release.

Move password recovery for saved hosts into host settings and keep pairing credentials in the add flow. Include isolated web QA evidence for both flows.

* fix(auth): preserve Hub bootstrap and local credential expectations

Hub-owned sockets already carry admission; keep their hello handling synchronous through session bootstrap while resolving credentials asynchronously for ordinary sockets. Keep the resolver out of lightweight configuration imports.

Update CLI expectations for same-home credentials, let a rejected connection yield to another usable connection, and clear pairing secrets when add-flow targets change.

* fix(pairing): retain passwords across edits to the same target

Track the last valid pairing host while a URI is edited. This preserves an entered password for whitespace and partial edits but clears it when a different host becomes the target.

* test(auth): align local credential checks across platforms

Expect the CLI to authenticate through the credential in its selected home, and verify POSIX file modes only where Node exposes them.

* feat(app): move host password into a settings row with a modal

The host page rendered an always-visible password form above Name on every
host. Replace it with a Password row under a Security section below
Appearance; tapping it opens a modal with Save and, when a password is saved,
Clear. The connection-error callout keeps its guidance and gains a Set
password button that opens the same modal. Drop the unused
pairing.hostPassword body/reopen/saveError strings.

* chore: remove committed QA screenshots

QA evidence stays out of the repository; the PR body describes it in words.

* feat(app): keep the host password out of the host page

The saved password is connection data, entered only when a host is added. A
Password setting on the host page reads as the daemon's own password, so the
row, modal, and callout button are gone. A rejected host shows the daemon's
reason with guidance to remove the host and add it again with the password.
useHostMutations no longer exposes setHostPassword; relay imports still set it
inside the runtime.

* feat(app): show host connection errors in an error alert

HostConnectionError renders the existing Alert with the connection error as
its title. Auth rejections add the re-add guidance as the description; other
errors show the title only. The Overview and Connections pages share it.

* style(app): soften alert borders to half-opacity accent

* feat(app): size alerts on the button scale

Alert takes a size prop (xs/sm/md/lg, default md) whose geometry lives next to
the button's in control-geometry: one font size for title and description, the
button icon size centered on the first text line, button padding and radius,
and a one-line alert as tall as the button of the same size. Consumers pick
sm among settings rows, cards, panels, and modals, and md when the alert is
the whole page or sheet state.

* style(app): roomier, rounder alerts with a tight title block

Alert sm padding returns to the original 12/16 and every size is at least
borderRadius.xl. Title and description sit a small gap apart with no line
height override. The icon slot carries a zero-width text strut in the alert's
font so the icon centers on the first line for titled, title-only, and
description-only alerts.

* refactor(app): align the alert icon with a centered lead row

Drop the zero-width strut. The icon and the alert's first text (title, else
description) share one row centered on each other; the description and actions
sit below, indented by the icon slot width plus gap so they start at the lead
text's left edge.

* refactor(app): drop the unused Alert icon override
2026-09-26 19:53:43 +02:00
Mohamed Boudra c081e0350d Support popular Pi extensions through per-extension adapters (#5309)
* refactor(server): give Pi extension handling a registry

* feat(server): map Pi task extensions to Paseo task lists

* feat(server): map Pi subagent extensions to the Subagents track

* refactor(server): hydrate Pi child sessions asynchronously through the extension host

Child session JSONL reads blocked the daemon during live events and replay. The extension host now prepares immediate upserts and bounded async timeline hydration; replay awaits it, and closed sessions discard it. Preserve claimed notification text so completion details remain visible in the parent conversation.

* feat(server): present Pi question extensions as one question form

Keep the extension questionnaire in one Paseo permission while Pi walks its RPC dialogs. The host defers an early dialog until the form is answered, then replies to each dialog using the collected choices.

* fix(server): satisfy Pi extension start hook contract

The server build checks the session interface more strictly than the fast typecheck. Return the optional permission explicitly so the adapter satisfies the published extension contract.

* docs: document Pi extension adapters

* chore(server): format Pi subagent captures

These real RPC fixtures were added before the formatter ran over the captured JSON. Normalize whitespace so repository format checks pass without changing the captured data.

* test(server): resolve Pi child fixtures as local paths on Windows

File URL pathnames retain a leading slash before a Windows drive letter. Convert fixture URLs to filesystem paths and escape those paths when inserting them into captured JSON, so child timeline assertions exercise the real file on every CI platform.

* refactor(server): remove Oh My Pi handling from the Pi provider

* fix(server): keep Pi extension adapter failures out of core session handling

* fix(server): bound Pi child-session hydration during history replay
2026-09-26 19:39:46 +02:00
paseo-ai[bot] e2de6df2e2 fix: update lockfile signatures and Nix hash [skip ci] 2026-09-26 15:28:40 +00:00
c906c2f4a4 fix(opencode): support v2 with automatic version selection (#5198)
* fix(opencode): support v2 while preserving v1 provider behavior

* fix(opencode): target v2.0.7, keeping v1 support

OpenCode 2.0.4 removed the /api/health endpoint and the plugin
await-activation endpoint, and moved form reply/cancel under the session
namespace. The v2 adapter was pinned to the 2.0.3 SDK, so any 2.0.4+
binary failed at helper-server startup with an opaque HTTP 404.

- Pin @opencode/client to 2.0.7 and require v2 >= 2.0.4 at runtime
  selection, with a clear error for older binaries.
- Use server.info for helper-server readiness.
- Move form reply/cancel/list to session.form and send the permission
  decision field instead of reply.
- Replace plugin.awaitActivation with a plugin.list readiness wait. A cold
  location registers its config-derived commands, skills, and providers
  asynchronously; an empty inventory means it is still warming.
- Send session.command.name, which replaced command, and drop the removed
  SkillInfo.slash field.
- Update the v2 test harness, unit tests, dependency lockfile, CI matrix,
  and provider docs to 2.0.7.

* fix(opencode): target 2.0.10 and address v2 review findings

* test(opencode): colocate v2 suites with their modules

Greptile's review flagged that the v2 tests lived in top-level
opencode-agent.*.test.ts files and imported the readiness helper, v2
client, harness, and API types from a distance. Move each v2 suite beside
the module it describes and leave the top-level files to the v1 public
path:

- opencode/v2/agent.test.ts: session lifecycle, structured output,
  resume, reconciliation, interrupt/replacement, helper isolation.
- opencode/v2/readiness.test.ts: location readiness deadline behavior.
- opencode/v2/permissions.test.ts: question and approval routing.
- opencode/v2/configuration.test.ts: permissionRules, feature flags, and
  helper-isolation decisions, exercised directly through the module.
- opencode/v2/mapping.test.ts: model and mode normalization.
- opencode/runtime-client.test.ts: runtime version selection.

The v1 recovery, permission-action, and catalog-deadline suites stay in
place. Test counts are preserved, with deeper per-module coverage for
configuration.

* test(opencode): split v2 local E2E by behavior and add harness helpers

The re-review flagged the v2 scenarios in opencode-bridge.local.e2e.test.ts
for inline event filtering, permission-kind branching, and polling in test
bodies. Move stream, permission, and subagent coordination behind helpers
in opencode/test-utils/v2-local-e2e-helpers.ts, and split the session
scenarios into opencode-v2.local.e2e.test.ts so each file covers one
behavior:

- opencode-bridge.local.e2e.test.ts: process sharing, shell.env scoping,
  the native tool bridge, and runtime version selection.
- opencode-v2.local.e2e.test.ts: session lifecycle, structured output,
  streaming, approvals, MCP preapproval, stop/replacement, rewind and
  subagents, commands, and skills/attachments.

Each helper waits on or answers the provider directly, so the tests state
domain intent and assert the collected result instead of polling or
branching inline. The CI-selected "versioned runtime" case stays in the
bridge file, and the live approvals and stop/replacement scenarios were
re-run green through the new helpers.

* fix(ci): preserve required server checks across OpenCode versions

Run v1 and v2 integration coverage sequentially so branch protection keeps its existing static check names.

* fix(agents): load startup history before registering sessions

A failed history stream while adding a runtime notice must close the unregistered session instead of leaving an initializing agent behind.

* fix(agents): limit startup history before buffering it

Apply the existing timeline content limits to each incoming history event so raw cumulative tool output is not retained until registration completes.

* fix(opencode): resolve Windows executables from platform packages

Recent v1 npm releases place opencode.exe in the architecture dependency rather than the CLI bin directory. Resolve that dependency from the CLI package while retaining support for the older layout.

* refactor(opencode): name public runtime option types

* test(opencode): let the spec own both runtime versions

Install each version in an isolated temporary directory and select it through provider configuration. Restore the CI workflow unchanged so version coverage is owned by the spec rather than workflow orchestration.

* test(opencode): launch native Windows binaries in isolated specs

Avoid npm command wrappers retaining locked executable files after helper shutdown during temporary installation cleanup.

* fix(opencode): preserve the legacy path when adding v2 support

Fall back to the existing client when version probing is inconclusive. Load v2 code and its bridge plugin only after positive v2 selection, keep v1 sessions undecorated, and restore original v1 discovery errors.

* fix(opencode): retry inconclusive version selection

A failed or unrecognized version probe previously cached the legacy fallback, pinning an upgraded binary to the v1 API until client recreation. Retry detection on the next operation while keeping the legacy path available.

* fix(opencode): release legacy runtime after inconclusive probe

Retryable version detection can clear the selected client after falling back to v1. Track whether the legacy client was selected so shutdown still releases its server without touching an unused v1 manager during a v2-only run.

* test(opencode): assert full runtime notice history

* fix(opencode): restore notice and retry behavior after rebase

---------

Co-authored-by: Jonathan karras <6307688+karrots@users.noreply.github.com>
Co-authored-by: Dhruv Singal <dhruvsingalabc@gmail.com>
2026-09-26 17:18:31 +02:00
paseo-bot[bot] fffd76e8fb List a custom Codex provider's sessions in Import session (#5446)
A provider that extends codex with its own OPENAI_BASE_URL starts its
threads under its own Codex model provider id. Codex's thread/list returns
only the model provider its config selects unless the request names
others, and the import scan named none, so those sessions were missing
from the import list. After switching the default provider in the Codex
config, stock Codex listed them instead and imported them onto itself.

Ask Codex for the custom provider's own threads when listing its
importable sessions. Stock Codex keeps its current listing.

Closes #5381

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-26 17:15:53 +02:00
Mohamed Boudra d2e2154a8a fix(app): keep restored workspaces during directory refresh (#5394)
* fix(app): keep restored workspaces during directory refresh

Capture workspace maps and cursors from one transaction baseline so cache hydration cannot pair an empty map with a current cursor. Reject legacy directory checkpoints to recover caches already persisted with missing workspaces.

* test(app): clarify workspace cache recovery flow
2026-09-26 16:58:53 +02:00
paseo-bot[bot] cb9654a65c fix(server): keep the daemon running when daemon.log cannot be written (#5445)
A failed write to daemon.log (for example ENOSPC on a full disk) made the
supervisor's rotating log stream emit an unhandled 'error' event, which
killed the supervisor; the worker then exited on lost supervisor liveness.

The supervisor now treats daemon.log as best effort: a failed stream is
reported once on stderr, dropped, and reopened on the next log line, so
logging resumes once the file is writable again.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-26 15:56:42 +02:00
paseo-bot[bot]andqinkangdeid 3dc17b7f30 fix(acp): keep the daemon running when an ACP agent cannot start (#5439)
Opening an archived ACP agent whose worktree was removed spawned the
provider in the missing directory. The session spawn had no listener for
the child's error event, so Node's ENOENT became an uncaught exception
and the daemon restarted, then crashed again when the client retried.

The session spawn now rejects initialization on a spawn error, the same
way the provider probe already did, and both share one helper.

Closes #5435

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: qinkangdeid <qinkangdeidd@gmail.com>
2026-09-26 15:10:44 +02:00
paseo-bot[bot] 76a9781ba5 Read Claude history from the provider's own config directory (#5437)
A provider that extends claude and sets CLAUDE_CONFIG_DIR in its env
writes transcripts and settings.json under that directory, but the
daemon looked them up under its own CLAUDE_CONFIG_DIR or ~/.claude.
Agents on that provider reopened with an empty timeline after a daemon
restart, their sessions were missing from the import list, and models
from its settings.json were missing from the catalog.

Resolve the config directory from the same environment the provider is
launched with, and drop the test-only configDir client option.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-26 13:57:27 +02:00
paseo-bot[bot] a272a22d7e fix(opencode): hide user messages OpenCode marks synthetic (#5434)
OpenCode adds text to user messages on its own behalf (continue prompts after
compaction or a subtask, MCP resource contents, plugin prompts) and flags that
text `synthetic: true`. Its own UI hides it. Paseo built user bubbles from every
text part, so reopening an OpenCode chat showed those messages as if the user
had typed them, and appended synthetic text to the user's own message.

Closes #5425

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-26 13:24:18 +02:00
paseo-bot[bot] 26f227bed9 fix(pi): keep a chat rewind after the daemon restarts (#5432)
Pi's navigateTree without a summary only moves the session's leaf in
memory and appends nothing to the session file. Pi reopens a session at
the file's last entry, so after a daemon restart the resumed chat showed
the messages the rewind had removed.

After navigating, the Paseo extension now appends a paseo_rewind custom
entry on the new branch. Custom entries carry no model context, and the
file's last entry now sits on the rewound branch, so Pi reopens there.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-26 11:58:43 +02:00
paseo-bot[bot] aa3ffaeb6c Refresh project skills in the / menu after the checkout's branch changes (#5415)
Draft slash commands are cached per provider and checkout with no expiry, so a skill from the previous branch stayed listed (or a new branch's skill stayed hidden) until the app restarted. A pushed checkout status that reports a different branch now drops that checkout's cached draft commands, and the next / menu fetches them again.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-26 11:06:22 +02:00
paseo-bot[bot] 513f2a9ea2 List a generic ACP agent's slash commands in a new agent's / menu (#5411)
* List a generic ACP agent's slash commands in a new agent's / menu

Generic ACP agents advertise slash commands with available_commands_update
after session/new. The new-agent / menu opens a short-lived session and asks
for its commands right after session/new, so it always got an empty list,
which the app keeps for the draft. Wait for the first batch, bounded by the
existing initial-commands timeout, as the Cursor, Kiro, and TRAE clients do.

Closes #4759

* Keep the generic ACP command test independent of scheduling

* Wait for agent state writes before removing MCP test temp dirs

The blocking send_agent_prompt timeout test removed its temp dir while
AgentManager background tasks and AgentStorage writes were still in
flight, which intermittently failed cleanup with ENOTEMPTY on Linux CI.
Flush both before removing the dir in the tests that use real storage.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-26 10:19:39 +02:00
paseo-bot[bot] 8cd989529e Notify a caller once when it prompts a child that is still running (#5407)
* Notify a caller once when it prompts a child that is still running

create_agent and a background send_agent_prompt each armed their own
finish notification for the same child and caller. When the second
prompt replaces the child's running turn, the child never goes idle in
between, so its one finish fired both and the caller got the same
notification twice. Arming again for a child and caller now replaces
the earlier notification.

Closes #5397

* Declare the notification test helper as a function

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 17:02:10 +02:00
paseo-bot[bot] 04c3e003f8 Keep model picker rows free of nested buttons on web (#5404)
* Keep the model row's profile action out of the row button on web

On web the model row renders as a <button>, and the create/edit profile
action inside it rendered a second <button>, so React logged "<button>
cannot be a descendant of <button>" for every row. The action now sits
beside the row button inside the same hover boundary, positioned over a
slot the row reserves, so layout and hover reveal are unchanged.

Closes #5385

* Check the create-profile action leaves the composer's model unchanged

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 16:19:28 +02:00
paseo-bot[bot] e68553f759 Run agents on another daemon from inside an agent session (#5392)
* Run agents on another daemon from inside an agent session

paseo run sent PASEO_AGENT_ID as the caller to whichever daemon --host or
--home pointed at. That id names an agent on the daemon that launched the
shell, so another daemon rejected the run with "Caller agent <id> not
found". The run now claims the caller only when the target daemon has
that agent, and otherwise runs as a top-level agent there.

Closes #5313

* Keep the caller when the agent lookup loses its connection

Only the daemon's answer that it has no such agent drops the caller. A
lost or timed-out connection now fails the run instead of creating a
top-level agent on the caller's own daemon.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 14:50:04 +02:00
paseo-bot[bot]andliujin0506 dc9799f6fc Open OSC 8 terminal links the same way as plain URLs (#5388)
* Open OSC 8 terminal hyperlinks like plain URLs

Clicking a link a CLI printed with OSC 8 (gh, npm, ls --hyperlink)
showed xterm's "Do you want to navigate to" prompt and then opened a
bare Paseo window, while the same URL printed as plain text opened in
the system browser. The terminal runtime now gives xterm a link handler
that sends OSC 8 links to the same external opener as plain URLs.

Closes #4768

Co-authored-by: liujin0506 <liujin0506@users.noreply.github.com>

* Name the hyperlink and line writes in the terminal link test

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: liujin0506 <liujin0506@users.noreply.github.com>
2026-09-25 13:54:49 +02:00
paseo-bot[bot] 43a2a7969c Rewind a Pi chat to the message you pick after an earlier rewind (#5383)
* Rewind a Pi chat to the message you pick after an earlier rewind

After a rewind, Pi's session file keeps the abandoned branch. Paseo
captured user entry ids from the whole file but replayed history from
the current branch, pairing them by position. Each abandoned user
message shifted later rows onto an earlier entry id, so rewinding a
replayed row went back further than the selected message.

Capture user entries from the same compaction-aware branch Pi replays,
so the nth capture is the nth replayed user message.

* Assert Pi entry captures as data with named test helpers

* Name the fields of Pi user entry test fixtures

* Rewind a Pi row whose entry compaction summarized away

The capture fed both replay pairing and the rewind target lookup. Narrowing
it to the context branch broke rewinding a live row from before /compact,
because that row's entry is off the branch. The extension now reports the
session's user entries for the lookup apart from the branch entries replay
pairs with.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 13:16:40 +02:00
Joe Shull 90d978ab68 fix(omp): preserve described select options (#3628)
* fix(omp): preserve described select options

* test(omp): assert exact option arrays instead of toMatchObject

toMatchObject ignores extra/missing keys, so the described-select and
malformed-metadata tests passed even with option-index misalignment or
an unfiltered whitespace-only description. Switched to toStrictEqual
on the options array so both are actually caught.
2026-09-25 10:25:19 +02:00
paseo-bot[bot] a048094da4 Scroll the theme list when plugin themes overflow the window (#5374)
* fix(app): scroll the theme list when plugin themes overflow the window

The theme menu lists every plugin-contributed theme but did not opt into
the menu surface's scrollable mode, so its height was never capped to the
window. A theme pack with dozens of entries pushed the lower themes off
screen with no way to reach them.

* test(app): scroll the theme menu through a named helper

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 09:14:24 +02:00
paseo-bot[bot] 315803688d Update and restart a daemon whose provider checks take over 1.5s (#5372)
The host settings Update and Restart buttons read the worker's status
before sending the request, with a 1500 ms timeout. The daemon answers
status only after probing each provider CLI with --version, which may
take up to 2 s per provider. On a host where one provider CLI starts
slowly, the pre-check always timed out and the request was never sent.

Read the status with the client's default request timeout, the same
full status read the supervisor restart test already exercises.

Closes #5367

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 08:10:09 +02:00
paseo-bot[bot] bf4dc22c2f Explain that daemon set-password needs a terminal when stdin is piped (#5358)
* Explain that daemon set-password needs a terminal when stdin is piped

The password prompt only resolves on a keypress from a terminal. With
stdin piped or redirected it never settled, so Node exited 13 with an
unsettled top-level await warning and no message. The command now fails
with PASSWORD_TTY_REQUIRED and points at PASEO_PASSWORD.

Closes #5350

* Assert the set-password terminal error message

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 06:54:28 +02:00
paseo-bot[bot] 5c9f767d51 Notify the caller when a blocking send_agent_prompt outlasts its 30s wait (#5347)
* fix(server): notify the caller when a blocking send_agent_prompt outlasts its wait

An agent-scoped send_agent_prompt with background: false only armed the
finish notification for background sends. When the 30s wait timed out,
the tool returned "still running" and nothing was armed, so the child's
result never reached the caller. The wait now reports whether it timed
out, and a timed-out wait on a still-running agent arms the same
notification a background send gets and says so in guidance. A blocking
send that finishes inside the wait still returns its result without a
notification.

* refactor(server): declare the finish-notification helper as a function

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 05:00:12 +02:00
paseo-bot[bot] 84304b5532 Start model-less Pi agents on Pi's configured default model (#5343)
A Pi agent created without a model took the first entry of Pi's model
list, which ignores defaultProvider/defaultModel in Pi's settings. The Pi
catalog now marks the model a fresh Pi session reports in get_state as
the default, so the daemon's default-model fallback picks it.

Closes #4918

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 01:59:26 +02:00
paseo-bot[bot] 28507224dc Show the date for a message from today's weekday last week (#5341)
A reply sent last Friday at 12:02 PM, read on Friday at 11:47 AM, was
labelled "Friday 12:02 PM" because the weekday window counted 24-hour
periods, and that reply was just under seven of them old. Count calendar
days instead, so the weekday form covers only the previous six days and
today's weekday a week back shows its date.

Closes #5225

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 00:54:41 +02:00
paseo-bot[bot] db4fd33406 Reconnect OpenCode agents to the new server after the old one exits (#5338)
* Reconnect OpenCode agents to the new server after the old one exits

When the OpenCode server process exits, Paseo starts the next one on a
new port, but existing sessions kept their client bound to the old port
and failed every turn with ECONNREFUSED until the agent was reloaded.

A session now marks its connection dead when the server exits and
connects to the current server before its next turn, rewind, history
read, mode or command lookup.

Closes #5307

* Settle the exited OpenCode server's events before reconnecting

A queued exit from the old server could otherwise fail a turn started on
the new one.

* Re-add a reconnected OpenCode session's MCP servers on the new server

reconnect() reset mcpConfigured but left mcpSetupPromise holding the
first server's resolved setup, so the next turn awaited it and never
called mcp.add on the new server. Track MCP setup with one field that
reconnect clears.

* Re-run CI after an unrelated macOS file-observation flake

* Re-run CI after an unrelated website WebKit timeout

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-25 00:02:53 +02:00
Mohamed Boudra e3c853df58 fix(android): avoid lint memory pressure in store builds 2026-09-24 12:46:57 +02:00
paseo-ai[bot] ea7a741851 fix: update lockfile signatures and Nix hash [skip ci] 2026-09-24 10:20:03 +00:00
498 changed files with 30865 additions and 7258 deletions
+1
View File
@@ -34,6 +34,7 @@ hub:
- "packages/server/src/server/hub/**"
server:
- "plugins/**"
- "packages/server/**"
- "packages/app/e2e/support/fixtures/recording.*"
+10
View File
@@ -117,9 +117,15 @@ jobs:
- name: Build server stack
run: npm run build:server
- name: Verify built-in plugins from built output
run: node --test scripts/builtin-plugins-dist.test.mjs
- name: Typecheck all packages
run: npm run typecheck
- name: Run website tests
run: npx vitest run packages/website/src --bail=1
- name: Install website test browsers
run: npx playwright install --with-deps chromium webkit
@@ -169,6 +175,10 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
- name: Run built-in plugin tests
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: npm run test --workspace=@getpaseo/builtin-plugins
- name: Run Hub CLI/daemon contract
if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }}
run: npm run test:hub-cli-contract --workspace=@getpaseo/server
+61
View File
@@ -1,5 +1,66 @@
# Changelog
## 0.10.1 - 2026-09-29
### Added
- Added Claude Sonnet 5.5 for Claude Code 2.1.284 and newer ([#5583](https://github.com/getpaseo/paseo/pull/5583) by [@yangqi](https://github.com/yangqi))
### Fixed
- Fixed OpenCode chats failing with "Variant unavailable" after switching to a model without the selected thinking level ([#5587](https://github.com/getpaseo/paseo/pull/5587))
- Fixed rewinding a Codex chat dropping its custom provider and Paseo tools ([#5345](https://github.com/getpaseo/paseo/pull/5345) by [@zbaibg](https://github.com/zbaibg))
- Fixed streamed replies joining lines of code blocks and Mermaid diagrams until the chat reloads ([#5577](https://github.com/getpaseo/paseo/pull/5577))
- Fixed a subagent opened from a split pane opening in a different pane ([#5451](https://github.com/getpaseo/paseo/pull/5451))
- Fixed archiving a custom Codex provider's agent leaving its session in Import session ([#5572](https://github.com/getpaseo/paseo/pull/5572))
- Fixed the `/` menu of a custom Codex provider with its own `CODEX_HOME` listing the daemon's prompts instead of its own ([#5450](https://github.com/getpaseo/paseo/pull/5450))
- Fixed a background `send_agent_prompt` returning `idle` for a prompt the agent accepted ([#5386](https://github.com/getpaseo/paseo/pull/5386))
- Fixed Windows file-link tooltips showing the full path for files inside the workspace ([#1987](https://github.com/getpaseo/paseo/pull/1987))
- Fixed terminal profiles that run `cursor-agent` showing the generic terminal icon ([#5379](https://github.com/getpaseo/paseo/pull/5379))
## 0.10.0 - 2026-09-28
### Added
- Added OpenCode v2 support, selected automatically from the installed `opencode` version ([#5198](https://github.com/getpaseo/paseo/pull/5198), [#5526](https://github.com/getpaseo/paseo/pull/5526) by [@karrots](https://github.com/karrots), [@dsingal0](https://github.com/dsingal0), [@gszep](https://github.com/gszep))
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats ([#5309](https://github.com/getpaseo/paseo/pull/5309))
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track ([#5309](https://github.com/getpaseo/paseo/pull/5309))
- Added rpiv-ask-user-question dialogs to Pi chats as one question form ([#5309](https://github.com/getpaseo/paseo/pull/5309))
- Added password checks to relay connections, rejecting an incorrect daemon password; a follow-up release will require the password ([#5393](https://github.com/getpaseo/paseo/pull/5393))
- Added a password field to Add host, pairing links, and QR pairing for password-protected daemons ([#5393](https://github.com/getpaseo/paseo/pull/5393))
- Added `relay://` connection strings to `paseo daemon pair` ([#5393](https://github.com/getpaseo/paseo/pull/5393))
- Added OMP Ask option descriptions to question cards on OMP 17.4.2 and newer ([#3628](https://github.com/getpaseo/paseo/pull/3628) by [@joeshull](https://github.com/joeshull))
### Changed
- Changed the CLI and desktop app to connect to a password-protected daemon on the same machine without asking for the password ([#5393](https://github.com/getpaseo/paseo/pull/5393))
- Changed a host rejected for its password to show "Password required" or "Incorrect password" on the host and Connections pages ([#5393](https://github.com/getpaseo/paseo/pull/5393))
- Reorganized Settings into General, Sidebar, Chat, Terminal, Browser, and Open location pages ([#5459](https://github.com/getpaseo/paseo/pull/5459))
### Fixed
- Fixed direct connections failing when the daemon password contains spaces or characters such as `@` or `/` ([#5393](https://github.com/getpaseo/paseo/pull/5393))
- Fixed existing OpenCode agents failing with `ECONNREFUSED` after the OpenCode server restarts ([#5338](https://github.com/getpaseo/paseo/pull/5338))
- Fixed messages OpenCode adds on its own appearing in the chat as if the user typed them ([#5434](https://github.com/getpaseo/paseo/pull/5434))
- Fixed the daemon stopping when `daemon.log` cannot be written, such as on a full disk ([#5445](https://github.com/getpaseo/paseo/pull/5445))
- Fixed the daemon crashing when opening an archived ACP agent whose worktree was removed ([#5439](https://github.com/getpaseo/paseo/pull/5439) by [@qinkangdeid](https://github.com/qinkangdeid))
- Fixed existing workspaces missing from the sidebar after the app starts, including after later restarts ([#5394](https://github.com/getpaseo/paseo/pull/5394))
- Fixed agents on a custom Claude provider with its own `CLAUDE_CONFIG_DIR` opening with an empty chat after a daemon restart ([#5437](https://github.com/getpaseo/paseo/pull/5437))
- Fixed a custom Codex provider's sessions missing from Import session ([#5446](https://github.com/getpaseo/paseo/pull/5446))
- Fixed Update daemon and Restart in host settings failing when a provider's version check takes over 1.5s ([#5372](https://github.com/getpaseo/paseo/pull/5372))
- Fixed `paseo --host <other daemon> run` failing with "Caller agent not found" from inside an agent session ([#5392](https://github.com/getpaseo/paseo/pull/5392))
- Fixed a Pi chat rewind going back further than the picked message after an earlier rewind ([#5383](https://github.com/getpaseo/paseo/pull/5383))
- Fixed a Pi chat rewind being undone when the daemon restarts ([#5432](https://github.com/getpaseo/paseo/pull/5432))
- Fixed Pi agents started without a model ignoring Pi's configured default model ([#5343](https://github.com/getpaseo/paseo/pull/5343))
- Fixed project skills in a new agent's `/` menu staying stale after the checkout switches branches ([#5415](https://github.com/getpaseo/paseo/pull/5415))
- Fixed slash commands missing from a new agent's `/` menu on custom ACP providers ([#5411](https://github.com/getpaseo/paseo/pull/5411))
- Fixed a parent agent receiving a child's finish notification twice after prompting the running child ([#5407](https://github.com/getpaseo/paseo/pull/5407))
- Fixed a blocking `send_agent_prompt` that outlasts its 30s wait never notifying the caller when the child finishes ([#5347](https://github.com/getpaseo/paseo/pull/5347))
- Fixed OSC 8 terminal links showing a navigation prompt and opening a blank Paseo window ([#5388](https://github.com/getpaseo/paseo/pull/5388) by [@liujin0506](https://github.com/liujin0506))
- Fixed the Theme menu not scrolling when plugin themes overflow the window ([#5374](https://github.com/getpaseo/paseo/pull/5374))
- Fixed a message from today's weekday last week showing only the weekday instead of its date ([#5341](https://github.com/getpaseo/paseo/pull/5341))
- Fixed `paseo daemon set-password` exiting silently when stdin is not a terminal ([#5358](https://github.com/getpaseo/paseo/pull/5358))
## 0.9.2 - 2026-09-24
### Added
+6 -2
View File
@@ -42,11 +42,15 @@ The daemon requires a valid cryptographic handshake before processing any comman
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
When a daemon password is configured, new relay clients send it in the encrypted `hello` message. This release still admits relay clients that send no credential so existing mobile builds continue to connect. A wrong password is rejected. The next release will require the password for relay connections after updated mobile builds are available.
## Local daemon trust boundary
By default, the daemon binds to `127.0.0.1`. With no password configured, the local control plane is trusted by network reachability — anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
By default, the daemon binds to `127.0.0.1`. With no password configured, anything that can reach the daemon socket can control the daemon. Loopback is reachable by other users on the machine and by some forwarding tools.
The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer <password>` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.<password>` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks.
The daemon supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). WebSocket clients send the password in `hello`; the daemon sends no session data before admission. Direct connections still accept bearer headers and WebSocket bearer subprotocols for older clients. HTTP stays bearer-header based. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt; `/api/files/download` and `/mcp/agents` use their own capability tokens.
The daemon writes a new `$PASEO_HOME/local-credential` on every run with mode `0600` and removes it on shutdown. The CLI and desktop main process read it only for the daemon whose PID lock `listen` matches their connection target. A same-user process can read this credential, so the password protects against network clients and other OS users, not processes running as the daemon user. Protect `$PASEO_HOME` accordingly. Relay traffic remains end-to-end encrypted independently of password admission.
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
+2
View File
@@ -48,6 +48,7 @@ $PASEO_HOME/
├── server-id # Stable daemon identifier (plain text, "srv_<base64url>")
├── daemon-keypair.json # E2EE keypair for relay (mode 0600)
├── paseo.pid # Daemon PID lock file
├── local-credential # Per-run local client credential (mode 0600)
├── daemon.log # Default log file (path configurable)
├── agents/
│ └── {sanitized-cwd}/
@@ -557,6 +558,7 @@ These small files are not validated as full Zod schemas but are persisted under
| `server-id` | Plain text, e.g. `srv_<base64url>` | Stable per-`$PASEO_HOME` daemon ID. Overridable via `PASEO_SERVER_ID` env. |
| `daemon-keypair.json` | `{ v: 2, publicKeyB64, secretKeyB64 }` (libsodium box keypair) | E2EE relay identity. Written with mode `0600`. Regenerated if file is unreadable. |
| `paseo.pid` | JSON `{ pid, startedAt, ... }` | PID lock; prevents two daemons sharing one `$PASEO_HOME`. |
| `local-credential` | 32 random bytes encoded as base64url text | Rotated before each listen and deleted on shutdown; mode `0600`. |
| `daemon.log` | Pino log output | Default location; path/rotation configurable via `log.file` in `config.json`. |
---
+4 -1
View File
@@ -19,7 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the "Sidebar" section on the Appearance settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/appearance/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
- **Sidebar items** — The top-level rows at the head of the app sidebar: New workspace, History, Search, Schedules, and every plugin sidebar contribution. Order and visibility are one per-device preference (`AppSettings.sidebarNavItems`); all of them may be off. UI: the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves the list, and both the sidebar (`components/sidebar/sidebar-nav-rows.tsx`) and the settings section (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems`. Never hardcode one of these rows or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
@@ -31,6 +31,9 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **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`).
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`).
- **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`).
+17 -1
View File
@@ -36,7 +36,8 @@ The daemon stores directory sources under the root `plugins` object:
}
```
The plugin system is disabled unless `pluginsEnabled` is `true`. Changing that root field is
Installed plugins are disabled unless `pluginsEnabled` is `true`. Built-in plugins remain active.
Changing that root field is
runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts every configured,
enabled plugin; disabling tears them all down without restarting the daemon. Plugin source entries
remain lifecycle-owned and do not reload from manual config edits.
@@ -85,6 +86,15 @@ plugin before compiling and starting from disk. A failed reload stays failed; Pa
the old code. Use `enable`, `disable`, and `remove` to manage one plugin. Removing a directory source
never deletes it. The global `pluginsEnabled` switch remains available.
## Built-in plugins
Built-in plugins live in `plugins/<id>/` and ship with the daemon. Add a directory and one ID to
`builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. The workspace, build
copy, and CI checks cover every listed directory; unlisted directories do not load. Built-ins run
in process, ignore `pluginsEnabled`, and do not appear in `config.json` or the installed plugin
list. Their client bundles appear in the plugin catalog. Editing one in development requires a
daemon restart. Directory, Git, and npm installs cannot use a built-in ID.
## Install a Git source
GitHub repositories use an `owner/repository` shorthand. Other hosts use a Git URL. An existing
@@ -376,6 +386,12 @@ external `href` or `xlink:href` references are rejected. Fragment references suc
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
SVG or URL.
## Usage sources
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. `input` is a Zod schema checked inside the plugin process before `fetch(input)` runs. `discover()` returns the inputs for accounts found on the host; an empty list omits the source from host usage. `fetch()` returns a report with a stable `account.key` so the daemon can deduplicate accounts and cache by source and input. A failed input or fetch becomes an error report for that source. `icon` uses the same sanitized SVG file rules as provider icons.
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version.
## Contribute buttons
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
+2
View File
@@ -10,6 +10,8 @@ Two contracts follow from it.
A schema change must not break parsing in either direction. An old app still parses messages from a new daemon. A new daemon still parses messages from an old app.
`hello` carries an optional credential. The daemon accepts a client protocol version from its minimum through newer client versions, selects the lower of client and daemon maximum, and reports it as optional `server_info.protocolVersion`. A `hello.rejected` frame precedes an auth close only when the client sent `hello.auth` or advertised `hello_rejection`; older clients receive the existing WebSocket close code and reason. This avoids sending them a new top-level message their validator does not recognize.
- New fields are `.optional()` with a sensible default.
- Never flip optional to required, remove a field, or narrow a type. `string` to `enum` and nullable to non-null are both narrowing.
- A field you stop sending stays accepted. You stop writing it, you don't stop reading it.
+15 -18
View File
@@ -69,7 +69,7 @@ model; it does not override the model list returned by a resolver.
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode/runtime-client.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
@@ -79,6 +79,8 @@ A provider that can register runtime tools directly should set `supportsNativePa
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
Pi extension adapters live under `packages/server/src/server/agent/providers/pi/extensions/<extension>/`. Each adapter turns Pi RPC facts into Paseo tool, timeline, subagent, or question mappings through the [extension contract](../packages/server/src/server/agent/providers/pi/extensions/contract.ts). To add one, create its directory, add one entry to `extensions/registry.ts`, and test it with fixtures captured from the real extension in Pi that record package, version, source commit, Pi version, and capture date. `agent.ts`, `history-mapper.ts`, and `tool-call-mapper.ts` never name an extension.
Paseo's per-agent and daemon-wide system prompts are appended by its generated Pi integration extension. Paseo deliberately does not pass `--append-system-prompt`, because that flag replaces Pi's automatic `APPEND_SYSTEM.md` discovery instead of composing with it.
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
@@ -95,9 +97,13 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
OpenCode 1 keeps MCP and process environment outside the session boundary. Paseo shares one OpenCode server for ordinary agents and installs a daemon-owned plugin through `OPENCODE_CONFIG_CONTENT`. The plugin reads the exact agent environment and caller-scoped Paseo tool catalog from the daemon's private loopback bridge for each OpenCode session. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifact contains no session data or secrets.
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. Keep that isolation until OpenCode exposes those values as session-owned configuration. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
V2.0.4 also removed the activation endpoint that gated a cold location, and a cold location registers its config-derived commands, skills, and providers asynchronously. Wait until `plugin.list` returns a populated inventory before reading the catalog or commands; an empty inventory means the location is still warming. Fail when the readiness deadline expires, including when an inventory request stalls.
Paseo installs its OpenCode tool bridge through `OPENCODE_CONFIG_CONTENT`. V1 accepts a plugin file; v2 silently skips configured files and requires a package directory with a server entry point. Both versions use the daemon's private loopback bridge for caller-scoped tools. Bridge context lives only in daemon memory and is removed when the Paseo session closes. The content-addressed plugin artifacts contain no session data or secrets. V2 also needs this plugin when native Paseo tools are disabled: its prompt API has no structured-output format, so the plugin supplies a schema-validated final-answer tool.
An agent with custom environment variables or user-configured MCP servers gets a dedicated OpenCode server. V2 supports session environment, but its MCP configuration remains location-scoped, so separate servers still prevent one agent from changing another agent's MCP setup. Configure custom MCP with `mcp.add`; do not follow it with `mcp.connect`, which only toggles config-backed servers.
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
@@ -189,26 +195,17 @@ promise for completion: equal results, including equal discovery timestamps, emi
---
## Provider Usage Fetchers
## Usage sources
Provider plan usage is fetch-on-demand, not a daemon push subscription. The app calls `provider.usage.list.request` through React Query when the usage tooltip or Host Usage settings screen is shown, and the daemon returns the normalized `ProviderUsage` list directly.
Usage is fetched on demand from plugin usage sources. Each source registers through `server.registerUsageSource()` with an input schema, `fetch(input)`, and optional `discover()`. The daemon discovers configured accounts, validates inputs in the plugin runtime, caches each source/input result for five minutes, and returns `usage.list_reports.response`. A source report has an account key, availability status, plan label, windows, balances, and details. Mark the window the app should show first with `headline: true`.
To add plan usage for a provider, add `packages/server/src/services/quota-fetcher/providers/<provider>.ts` and register it in `packages/server/src/services/quota-fetcher/manifest.ts`. The provider file exports only its fetcher class; provider auth, endpoint constants, API schemas, and normalization helpers stay private in that file. A fetcher owns provider auth/API parsing and returns the generic shape:
Create a built-in source under `plugins/<name>-usage-source/` with the same manifest, entry, `server/`, `shared/`, and `icon.svg` layout as an external plugin. Add its ID to `builtinPlugins` in `packages/server/src/server/plugins/builtin/index.ts`. Keep credential discovery, API parsing, and normalization inside the source; use helpers from `@getpaseo/plugin/server/usage`. The wire shape remains source agnostic. See [plugin usage sources](plugins.md#usage-sources).
- `providerId`, `displayName`, `status`, and optional `planLabel`
- any number of `windows` such as Session, Weekly, or Biweekly
- optional `balances` for credits, USD, requests, or tokens
- optional `details` for provider-specific rows
`provider.usage.list` remains a compatibility RPC for older apps. It maps discovered reports to `ProviderUsage`. New clients use `usage.list_reports` after checking `server_info.features.usageSources`.
Keep the protocol shape provider-agnostic. Do not add provider-specific renderers for new limit windows; labels and generic bars should carry the UI. API responses should be parsed and normalized with Zod inside the fetcher, while the protocol boundary stays strict so old/new client compatibility is explicit.
### Credentials are read only
Kimi Code usage follows the CLI-managed credential file at `KIMI_CODE_HOME` or `~/.kimi-code/credentials/kimi-code.json`; do not probe the legacy `~/.kimi` path as the primary source for current Kimi Code installs.
Cursor usage reads the desktop `state.vscdb` token first, then `cursor-agent`'s `~/.config/cursor/auth.json`. Headless hosts only have the CLI file.
### Usage fetchers are read-only on credentials
A fetcher reads the provider's credential file and never writes it. On a 401 or 403 it returns `unavailable` and leaves refresh to the provider's own CLI: redeeming a refresh token in the fetcher invalidates the CLI's copy (refresh tokens are single-use), and rewriting the file through the fetcher's Zod schema drops any field the schema does not model, corrupting the file for the CLI.
A source reads provider credentials without writing them. On 401 or 403 it returns `unavailable` and leaves refresh to the provider CLI. Redeeming a refresh token here would invalidate the CLI's copy; rewriting a parsed credential file could drop fields the source does not model.
---
@@ -0,0 +1,7 @@
Added
- Added OpenCode v2 support, selected automatically from the installed opencode version
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Added
- Added OpenCode v2 support, selected automatically from the installed opencode version
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Added
- Added OpenCode v2 support, selected automatically from the installed opencode version
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Added
- Added OpenCode v2 support, selected automatically from the installed opencode version
- Added task lists from rpiv-todo, pi-goal-x, and Pi's example todo extension to Pi chats
- Added subagent runs from pi-subagents, Tintinweb pi-subagents, and Gotgenes pi-subagents to the Subagents track
- Added rpiv-ask-user-question dialogs to Pi chats as one question form
Full notes: https://paseo.sh/changelog
+4
View File
@@ -1,6 +1,10 @@
{
"$schema": "./node_modules/knip/schema.json",
"workspaces": {
"plugins": {
"entry": ["**/index.{server,client}.{ts,tsx}", "**/*.test.ts"],
"project": ["**/*.{ts,tsx}"]
},
".": {
"entry": ["scripts/**/*.{js,mjs,cjs,ts}"],
"project": ["scripts/**/*.{js,mjs,cjs,ts}"]
+1 -1
View File
@@ -1 +1 @@
sha256-9UWtpZrCdyYyGq3HGNgSpU1+2Imu3oYqtSumq2DtANc=
sha256-pVwIsufl/4VVcTiqJYbqy4hNnL2O5EBpl5DN5As+20c=
+285 -27
View File
@@ -1,15 +1,16 @@
{
"name": "paseo",
"version": "0.9.2",
"version": "0.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.9.2",
"version": "0.10.0",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
"plugins",
"packages/expo-two-way-audio",
"packages/highlight",
"packages/plugin",
@@ -6333,6 +6334,10 @@
"resolved": "packages/app",
"link": true
},
"node_modules/@getpaseo/builtin-plugins": {
"resolved": "plugins",
"link": true
},
"node_modules/@getpaseo/cli": {
"resolved": "packages/cli",
"link": true
@@ -8271,6 +8276,84 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
@@ -8644,6 +8727,48 @@
"node": ">=20.0"
}
},
"node_modules/@opencode/client": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.10.tgz",
"integrity": "sha512-Pz4FD6jVtfZYX97AghVTM+cedi3HfI+U6gg/we5Luf08L85TewKsSXR4jHOZTCvggl0vACTquz1/hOjsJWE0Sg==",
"license": "MIT",
"dependencies": {
"@opencode/protocol": "2.0.10",
"@opencode/schema": "2.0.10"
},
"peerDependencies": {
"effect": "4.0.0-rc.112",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
"effect": {
"optional": true
},
"solid-js": {
"optional": true
}
}
},
"node_modules/@opencode/protocol": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.10.tgz",
"integrity": "sha512-cHr0aSK39Q9c3JwPDvG45FFlE6rU/ntTFDSaqm4LRl74tki88G4WpzKk6Q56G06K6hKIONrezcPdXtDKtnZzyw==",
"license": "MIT",
"dependencies": {
"@opencode/schema": "2.0.10",
"effect": "4.0.0-rc.112"
}
},
"node_modules/@opencode/schema": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.10.tgz",
"integrity": "sha512-RpRhRZFH1YFnM+OcrxOZc2ObIW3RQ63z/LkcHrZKa47kWF0OiaZVkbAOITeAoAoofcnS3O5ekZBvgT/7kBwRUA==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "1.1.0",
"effect": "4.0.0-rc.112"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
@@ -18622,6 +18747,16 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/effect": {
"version": "4.0.0-rc.112",
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.112.tgz",
"integrity": "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==",
"license": "MIT",
"dependencies": {
"fast-check": "^4.9.0",
"msgpackr": "^2.0.5"
}
},
"node_modules/ejs": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
@@ -21866,6 +22001,28 @@
"node": ">=18"
}
},
"node_modules/fast-check": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.0.tgz",
"integrity": "sha512-hhqQL+IJllZi3aM4TKvmCj3bywLEcycNTTLZeLhA9ttMxBrCqM07q7Di4kl+j9EWSTXvJH1+EpIgsDbF/+8H5Q==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^8.0.0"
},
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/fast-copy": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
@@ -28141,6 +28298,37 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/msgpackr": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz",
"integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.4"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
}
},
"node_modules/multipasta": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
@@ -28466,6 +28654,21 @@
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/node-gyp/node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@@ -30141,6 +30344,22 @@
"node": ">=6"
}
},
"node_modules/pure-rand": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
"integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
@@ -36196,7 +36415,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.9.2",
"version": "0.10.0",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37262,12 +37481,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.9.2",
"version": "0.10.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.9.2",
"@getpaseo/protocol": "0.9.2",
"@getpaseo/server": "0.9.2",
"@getpaseo/client": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/server": "0.10.0",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37514,10 +37733,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.9.2",
"version": "0.10.0",
"dependencies": {
"@getpaseo/protocol": "0.9.2",
"@getpaseo/relay": "0.9.2",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/relay": "0.10.0",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37528,7 +37747,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.9.2",
"version": "0.10.0",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37772,7 +37991,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.9.2",
"version": "0.10.0",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38668,7 +38887,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.9.2",
"version": "0.10.0",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38697,14 +38916,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.9.2",
"version": "0.10.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.9.2",
"@getpaseo/protocol": "0.9.2",
"@getpaseo/client": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38714,8 +38933,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.9.2",
"@getpaseo/protocol": "0.9.2",
"@getpaseo/client": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38737,7 +38956,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.9.2",
"version": "0.10.0",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38764,7 +38983,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.9.2",
"version": "0.10.0",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38982,19 +39201,20 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.9.2",
"version": "0.10.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.9.2",
"@getpaseo/highlight": "0.9.2",
"@getpaseo/plugin": "0.9.2",
"@getpaseo/protocol": "0.9.2",
"@getpaseo/relay": "0.9.2",
"@getpaseo/client": "0.10.0",
"@getpaseo/highlight": "0.10.0",
"@getpaseo/plugin": "0.10.0",
"@getpaseo/protocol": "0.10.0",
"@getpaseo/relay": "0.10.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
"@opencode/client": "2.0.10",
"@xterm/headless": "^6.0.0",
"ai": "5.0.78",
"ajv": "^8.20.0",
@@ -39020,9 +39240,11 @@
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"typescript": "^5.9.3",
"undici": "^7.30.0",
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
"yaml": "2.9.1",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -39749,6 +39971,15 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"packages/server/node_modules/undici": {
"version": "7.30.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz",
"integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"packages/server/node_modules/vitest": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
@@ -39881,6 +40112,21 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"packages/server/node_modules/yaml": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"packages/server/node_modules/yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
@@ -39895,7 +40141,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.9.2",
"version": "0.10.0",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -41497,6 +41743,18 @@
"optional": true
}
}
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.0.0",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^20.9.0",
"vitest": "^4.1.6"
}
}
}
}
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.9.2",
"version": "0.10.0",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -21,6 +21,7 @@
"url": "https://github.com/getpaseo/paseo.git"
},
"workspaces": [
"plugins",
"packages/expo-two-way-audio",
"packages/highlight",
"packages/plugin",
@@ -66,7 +67,7 @@
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
"typecheck": "npm run typecheck --workspaces --if-present",
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/builtin-plugins && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
"test": "npm run test --workspaces --if-present",
"format": "oxfmt .",
"format:files": "oxfmt",
@@ -6,13 +6,16 @@ import {
expectAgentProfilesEmptyPrompt,
expectProfileEditTooltip,
expectComposerMode,
expectCreateProfileFromModelRow,
expectComposerModel,
expectModelRowProfileActionBesideRow,
expectModelRowSelected,
expectProfileEditIsPencilOnly,
expectProfileVisibleForProvider,
openModelPicker,
openAgentProfilesFromEmptyPrompt,
seedAgentProfiles,
selectModelRow,
} from "../support/helpers/agent-profiles";
import { expectWorkspaceAgentConfiguration } from "../support/helpers/command-center-agent-controls";
import { expectComposerVisible } from "../support/helpers/composer";
@@ -115,4 +118,34 @@ test.describe("Agent profiles in the model picker", () => {
await seed.restore();
}
});
test("a model row and its create-profile action are separate buttons", async ({ page }) => {
const seed = await seedAgentProfiles([]);
const workspace = await seedMockAgentWorkspace({
repoPrefix: "agent-profiles-row-action-",
title: "Agent profiles row action",
model: "ten-second-stream",
modeId: "load-test",
});
try {
const oneMinute = {
provider: "mock",
modelId: "one-minute-stream",
modelLabel: "One minute stream",
};
await openAgentRoute(page, workspace);
await expectComposerVisible(page);
await openModelPicker(page);
await expectModelRowProfileActionBesideRow(page, oneMinute);
await expectCreateProfileFromModelRow(page, oneMinute);
await expectComposerModel(page, "Ten second stream");
await openModelPicker(page);
await selectModelRow(page, oneMinute);
await expectComposerModel(page, oneMinute.modelLabel);
} finally {
await workspace.cleanup();
await seed.restore();
}
});
});
@@ -0,0 +1,51 @@
import { expect, test } from "../support/fixtures";
import { openCommandCenter } from "../support/helpers/command-center";
import { getServerId } from "../support/helpers/server-id";
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { resetSeededPageState, openSessions } from "../support/helpers/archive-tab";
import { closeSidebarDisplayPreferences, openSidebarDisplayPage } from "../support/helpers/sidebar";
test("agent list age advances and command center says just now for a fresh agent", async ({
page,
}) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "agent-relative-time-",
title: "Relative time agent",
});
try {
await session.client.waitForAgentUpsert(session.agentId, (agent) => agent.status === "idle");
await resetSeededPageState(page);
await page.clock.install({ time: Date.now() });
await openSessions(page);
const row = page.getByTestId(`agent-row-${getServerId()}-${session.agentId}`);
await expect(row).toContainText("just now", { timeout: 30_000 });
const commandCenter = await openCommandCenter(page);
await commandCenter.getByTestId("command-center-input").fill("Relative time agent");
const agentResult = commandCenter.getByTestId(
`command-center-agent-${getServerId()}:${session.agentId}`,
);
await expect(agentResult.getByTestId("command-center-agent-subtitle")).toContainText(
"just now",
);
await page.keyboard.press("Escape");
await page.clock.fastForward("03:00");
await expect(row).toContainText("3m ago");
await openAgentRoute(page, session);
await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().hover();
await expect(page.getByTestId(`workspace-tab-tooltip-agent_${session.agentId}`)).toContainText(
"3m ago",
);
await openSidebarDisplayPage(page, "sidebar-display-show");
await page.getByRole("menuitem", { name: "Last activity", exact: true }).click();
await closeSidebarDisplayPreferences(page);
await expect(page.getByTestId("sidebar-workspace-timestamp").first()).toHaveText("3m");
} finally {
await session.cleanup();
}
});
@@ -17,12 +17,14 @@ import {
hoverChatOutlinePrompt,
chatOutlineRail,
movePointerOffChatOutline,
observePromptIndexRequests,
pointAtChatOutlineRowEdge,
pressEnterOnFocusedPrompt,
splitCurrentPanelRight,
withStreamingMarkdownOutline,
expectReadingStreamedMarkdown,
} from "../support/helpers/chat-outline";
import { createCreationScenario } from "../support/helpers/creation";
import {
expectTimelineAtMaximumScrollWithPromptVisible,
expectTimelinePromptNotMounted,
@@ -266,3 +268,21 @@ test.describe("desktop chat outline", () => {
}
});
});
test("indexes a new agent's prompts only once the agent exists", async ({ page }) => {
const promptIndex = await observePromptIndexRequests(page);
const creation = await createCreationScenario(page);
try {
await page.setViewportSize(WIDE_VIEWPORT);
await creation.openAgentDraft();
await creation.submitPrompt("Start an agent with an outline.");
await creation.expectPromptVisible();
const agentIds = await creation.agentIds();
expect(agentIds).toHaveLength(1);
await promptIndex.waitForRequestFor(agentIds[0]!);
expect(promptIndex.requestedAgentIds()).toEqual(agentIds);
} finally {
await creation.cleanup();
}
});
@@ -87,7 +87,25 @@ test("commit history shows dates and shares diff layout preferences", async ({
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible();
});
async function createFeatureCommit(repoPath: string): Promise<void> {
test("a fresh commit says just now and its age advances", async ({ page, withWorkspace }) => {
const workspace = await withWorkspace({ prefix: "commit-relative-time-" });
const committedAt = new Date();
await createFeatureCommit(workspace.repoPath, committedAt.toISOString());
await page.clock.install({ time: committedAt });
await workspace.navigateTo();
await openChangesTreePanel(page);
await page.getByRole("button", { name: /Commits/i }).click();
const row = page.locator('[data-testid^="commit-row-"]').filter({ hasText: COMMIT_SUBJECT });
await expect(row).toContainText("just now", { timeout: 30_000 });
await page.clock.fastForward("03:00");
await expect(row).toContainText("3m ago");
});
async function createFeatureCommit(
repoPath: string,
authorDate = "2020-01-15T12:00:00Z",
): Promise<void> {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoPath, stdio: "ignore" });
await writeFile(path.join(repoPath, "feature.txt"), "before\nafter\n");
execFileSync("git", ["add", "feature.txt"], { cwd: repoPath, stdio: "ignore" });
@@ -96,8 +114,8 @@ async function createFeatureCommit(repoPath: string): Promise<void> {
stdio: "ignore",
env: {
...process.env,
GIT_AUTHOR_DATE: "2020-01-15T12:00:00Z",
GIT_COMMITTER_DATE: "2020-01-15T12:00:00Z",
GIT_AUTHOR_DATE: authorDate,
GIT_COMMITTER_DATE: authorDate,
},
});
}
@@ -10,6 +10,7 @@ import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { getServerId } from "../support/helpers/server-id";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace";
const TEST_COMMANDS = [
{
@@ -338,6 +339,16 @@ function expectPopoverFramesStable(frames: PopoverFrame[]): void {
).toBeUndefined();
}
function recordCommandRequestsByAgentId(page: Page): string[] {
const requests: string[] = [];
const record = ({ payload }: { payload: string | Buffer }) => {
if (typeof payload !== "string" || !payload.includes("list_commands_request")) return;
if (!payload.includes("draftConfig")) requests.push(payload);
};
page.on("websocket", (socket) => socket.on("framesent", record));
return requests;
}
function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]): void {
const firstVisibleIndex = frames.findIndex(
(frame) =>
@@ -359,6 +370,25 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
}
test.describe("Composer autocomplete", () => {
test("keeps New workspace off the running-agent command list before a project is chosen", async ({
page,
}) => {
const commandRequestsByAgentId = recordCommandRequestsByAgentId(page);
await page.goto("/new");
await expectNewWorkspaceProjectSelected(page, "Choose project");
await composerLocator(page).fill("/");
const popover = page
.getByTestId("composer-autocomplete-popover")
.filter({ visible: true })
.first();
await expect(popover).toContainText("Choose a project to see commands", { timeout: 30_000 });
await expect(popover).not.toContainText("/clear");
await expect(popover).not.toContainText("/exit");
expect(commandRequestsByAgentId).toEqual([]);
});
test("stays visible after returning from app-wide routes", async ({ page }) => {
await installListCommandsStub(page);
const serverId = getServerId();
@@ -0,0 +1,71 @@
import type { Locator } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
// Where the progress arc is painted, as its centroid relative to the ring's centre in pixels.
// Reads the rendered pixels, so any rotation that does not reach the screen counts as none.
async function progressArcCentroid(meter: Locator): Promise<{ x: number; y: number }> {
const ring = meter.locator("svg");
const progressColor = await ring
.locator("circle")
.last()
.evaluate((circle) => getComputedStyle(circle).stroke);
const screenshot = await ring.screenshot();
return ring.evaluate(
async (_svg, { png, color }) => {
const image = await createImageBitmap(
await (await fetch(`data:image/png;base64,${png}`)).blob(),
);
const canvas = new OffscreenCanvas(image.width, image.height);
const context = canvas.getContext("2d")!;
context.drawImage(image, 0, 0);
const { data } = context.getImageData(0, 0, image.width, image.height);
const [r, g, b] = color.match(/\d+/g)!.map(Number);
let sumX = 0;
let sumY = 0;
let count = 0;
for (let y = 0; y < image.height; y += 1) {
for (let x = 0; x < image.width; x += 1) {
const i = (y * image.width + x) * 4;
const distance =
Math.abs(data[i] - r) + Math.abs(data[i + 1] - g) + Math.abs(data[i + 2] - b);
if (distance < 40) {
sumX += x;
sumY += y;
count += 1;
}
}
}
if (count === 0) {
throw new Error(`No pixels painted in the progress colour ${color}`);
}
return { x: sumX / count - image.width / 2, y: sumY / count - image.height / 2 };
},
{ png: screenshot.toString("base64"), color: progressColor },
);
}
test.describe("context window meter", () => {
test("draws usage clockwise from twelve o'clock", async ({ page }) => {
test.setTimeout(180_000);
// 32,000 of the mock's 128,000-token window: a quarter, from twelve to three o'clock.
const session = await seedMockAgentWorkspace({
repoPrefix: "context-window-meter-",
title: "Context window meter e2e",
initialPrompt: "emit 32000 byte file agent stream payload",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
const meter = page.getByTestId("context-window-meter");
await expect(meter).toHaveAccessibleName(/25%/, { timeout: 30_000 });
const centroid = await progressArcCentroid(meter);
expect(centroid.x).toBeGreaterThan(1);
expect(centroid.y).toBeLessThan(-1);
} finally {
await session.cleanup();
}
});
});
@@ -1,4 +1,7 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "../support/fixtures";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import {
@@ -25,3 +28,39 @@ test("settings restart waits for a replacement worker while retaining the superv
await daemon.close();
}
});
test("settings restart replaces the worker when a provider CLI answers --version slowly", async ({
page,
}) => {
test.skip(process.platform === "win32", "The slow provider stub is a POSIX script");
const daemon = await startIsolatedHostDaemon(randomUUID(), {
paseoHome: await createHomeWithSlowProvider(1_800),
});
try {
const previousWorker = await readWorkerPid(daemon);
await openDaemonOverview(page, daemon);
await restartDaemonInSettings(page);
await expectDaemonRestartComplete(page);
expect(await readWorkerPid(daemon)).not.toBe(previousWorker);
} finally {
await daemon.close();
}
});
async function createHomeWithSlowProvider(versionDelayMs: number): Promise<string> {
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-slow-provider-"));
const provider = path.join(paseoHome, "slow-provider");
await writeFile(
provider,
`#!${process.execPath}\nsetTimeout(() => console.log("provider 1.0.0"), ${versionDelayMs});\n`,
{ mode: 0o700 },
);
await writeFile(
path.join(paseoHome, "config.json"),
`${JSON.stringify({
version: 1,
agents: { providers: { claude: { command: { mode: "replace", argv: [provider] } } } },
})}\n`,
);
return paseoHome;
}
@@ -7,7 +7,11 @@ import {
expectFileTabOpen,
} from "../support/helpers/file-explorer";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { gotoAppShell } from "../support/helpers/app";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
const APP_SETTINGS_KEY = "@paseo:app-settings";
@@ -92,6 +96,40 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
test("shows an absolute POSIX assistant file link relative to the workspace on hover", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "file-link-tooltip-" });
const filePath = path.join(workspace.repoPath, "src", "target.ts");
try {
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Absolute file link tooltip",
modeId: "load-test",
model: "e2e-fast-stream",
initialPrompt: "Show the file link",
featureValues: { mockAssistantResponse: `[Open target](${filePath}:42-45)` },
});
await gotoAppShell(page);
await waitForSidebarHydration(page);
const workspaceRow = page.getByTestId(
`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`,
);
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
await workspaceRow.click();
await page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).click();
const fileLink = page.getByRole("link", { name: "Open target" }).first();
await expect(fileLink).toBeVisible({ timeout: 15_000 });
await fileLink.hover();
await expect(page.getByText("src/target.ts:42-45", { exact: true })).toBeVisible();
} finally {
await workspace.cleanup();
}
});
test("renders a lockfile-sized read-only source with a bounded CodeMirror DOM", async ({
page,
}) => {
@@ -0,0 +1,111 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { addConnectedHostAndReload } from "../support/helpers/hosts";
import { openHostSection, selectSettingsHost } from "../support/helpers/settings";
import { getServerId } from "../support/helpers/server-id";
import {
startRestartableHostDaemon,
type RestartableHostDaemon,
} from "../support/helpers/versioned-host-daemon";
const PREVIOUS_VERSION = "0.8.0";
const UPDATED_VERSION = "0.9.1";
const HOST_LABEL = "Version restart QA";
const NO_RELOAD_MARKER = "before-restart";
interface ReloadMarkerWindow {
__paseoE2eHostVersionMarker?: string;
}
test.describe.configure({ timeout: 120_000 });
let hostDaemon: RestartableHostDaemon | null = null;
test.afterEach(async () => {
await hostDaemon?.dispose();
hostDaemon = null;
});
test("host page shows the restarted daemon's version without reloading", async ({ page }) => {
const host = await startRestartableHostDaemon(PREVIOUS_VERSION);
hostDaemon = host;
const otherHostVersion =
await test.step("the connected host reports its current daemon version", async () => {
await gotoAppShell(page);
await addConnectedHostAndReload(page, {
serverId: host.serverId,
label: HOST_LABEL,
port: host.port,
});
const version = await readHelpHostVersion(page, getServerId());
await expectHelpHostVersion(page, host.serverId, PREVIOUS_VERSION);
await openHostPage(page, host);
await expect(hostIdentity(page)).toContainText("Online");
await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toBeVisible();
await markPageForNoReloadCheck(page, NO_RELOAD_MARKER);
return version;
});
await test.step("the last known version remains on the host page when the host stops", async () => {
await host.stop();
await expect(hostIdentity(page)).toContainText("Error");
await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toBeVisible();
});
await test.step("the host daemon restarts on the new version", async () => {
await host.restartWithVersion(UPDATED_VERSION);
});
await test.step("the host page badge follows the new version", async () => {
await expect(hostVersionBadge(page, UPDATED_VERSION)).toBeVisible({ timeout: 30_000 });
await expect(hostVersionBadge(page, PREVIOUS_VERSION)).toHaveCount(0);
await page.getByRole("button", { name: "Back" }).click();
await expectHelpHostVersion(page, host.serverId, UPDATED_VERSION);
expect(await readHelpHostVersion(page, getServerId())).toBe(otherHostVersion);
await expectNoReloadSinceMarker(page);
});
});
async function readHelpHostVersion(page: Page, serverId: string): Promise<string> {
await page.getByTestId("sidebar-help").click();
const row = page.getByTestId(`sidebar-help-host-version-${serverId}`);
await expect(row).toBeVisible();
const text = await row.innerText();
await page.keyboard.press("Escape");
return text;
}
async function expectHelpHostVersion(page: Page, serverId: string, version: string): Promise<void> {
const text = await readHelpHostVersion(page, serverId);
expect(text).toContain(`v${version}`);
}
async function openHostPage(page: Page, host: RestartableHostDaemon): Promise<void> {
await openSettings(page);
await selectSettingsHost(page, host.serverId);
await openHostSection(page, host.serverId, "host");
}
function hostIdentity(page: Page) {
return page.getByTestId("host-page-identity");
}
function hostVersionBadge(page: Page, version: string) {
return hostIdentity(page).getByText(`v${version}`, { exact: true });
}
async function markPageForNoReloadCheck(page: Page, marker: string): Promise<void> {
// The marker has to cross into the page as an argument: the callback runs in the browser, so a
// captured Node-side constant is not defined there.
await page.evaluate((value) => {
(window as typeof window & ReloadMarkerWindow).__paseoE2eHostVersionMarker = value;
}, marker);
}
async function expectNoReloadSinceMarker(page: Page): Promise<void> {
const marker = await page.evaluate(
() => (window as typeof window & ReloadMarkerWindow).__paseoE2eHostVersionMarker ?? null,
);
expect(marker).toBe(NO_RELOAD_MARKER);
}
@@ -4,7 +4,7 @@ import { copyFile, mkdir, rm, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { TestInfo } from "@playwright/test";
import { test, type Page } from "../support/fixtures";
import { expect, test, type Page } from "../support/fixtures";
import { ImportSessionFlow } from "../support/helpers/import-session";
import {
connectNewWorkspaceDaemonClient,
@@ -49,6 +49,7 @@ interface ImportFlowScenario {
worktreeDirectory: string;
unrelatedDirectory: string;
importSessionId: string;
importSessionTimestamp: number;
repoCleanup(): Promise<void>;
unrelatedCleanup(): Promise<void>;
}
@@ -94,7 +95,7 @@ test.beforeAll(async () => {
};
const reuseTarget = await openProjectViaDaemon(client, unrelated.path);
const importSessionId = "fixture-custom-title";
await seedClaudeSessions({
const importSessionTimestamp = await seedClaudeSessions({
projectRoot: repo.path,
worktreeDirectory,
unrelatedDirectory: unrelated.path,
@@ -108,6 +109,7 @@ test.beforeAll(async () => {
worktreeDirectory,
unrelatedDirectory: unrelated.path,
importSessionId,
importSessionTimestamp,
repoCleanup: repo.cleanup,
unrelatedCleanup: unrelated.cleanup,
};
@@ -122,6 +124,19 @@ test.afterAll(async () => {
await rm(claudeConfigDirectory, { recursive: true, force: true });
});
test("an open Import Session row keeps its age current", async ({ page }) => {
await page.clock.install({ time: scenario.importSessionTimestamp + 60_000 });
const flow = new ImportSessionFlow(page);
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
await flow.revealMobileEntryPoint();
await flow.openGlobally();
const row = page.getByTestId(`import-session-session-claude-${scenario.importSessionId}`);
await expect(row).toContainText("1m ago");
await page.clock.fastForward("03:00");
await expect(row).toContainText("4m ago");
});
test("captures the compact import-session journey", async ({ page }, testInfo) => {
const flow = new ImportSessionFlow(page);
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
@@ -225,7 +240,7 @@ async function seedClaudeSessions(input: {
worktreeDirectory: string;
unrelatedDirectory: string;
importSessionId: string;
}): Promise<void> {
}): Promise<number> {
const sessions = [
{
cwd: input.projectRoot,
@@ -290,4 +305,5 @@ async function seedClaudeSessions(input: {
const timestamp = new Date(newest - index * 60_000);
await utimes(sessionPath, timestamp, timestamp);
}
return newest;
}
@@ -250,6 +250,52 @@ test.describe("Tab creation", () => {
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
});
test("Cursor CLI profile uses the Cursor icon in New tab and host settings", async ({ page }) => {
const guessed: TerminalProfile = {
id: "e2e-cursor-guessed",
name: "Cursor guessed",
command: "cursor-agent",
args: ["{{{prompt}}}"],
};
const explicit: TerminalProfile = {
...guessed,
id: "e2e-cursor-explicit",
name: "Cursor explicit",
icon: "cursor",
};
const profileSeed = await seedTerminalProfiles([guessed, explicit]);
try {
await gotoWorkspace(page, workspace.workspaceId);
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
const menuIconPath = (name: string) =>
menu
.getByRole("menuitem", { name })
.evaluate((element) => element.querySelector("svg path")?.getAttribute("d") ?? null);
const guessedMenuPath = await menuIconPath(guessed.name);
const explicitMenuPath = await menuIconPath(explicit.name);
await menu.getByTestId("workspace-new-tab-menu-edit-terminal-profiles").click();
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/terminals$/);
const settingsIconPath = (id: string) =>
page
.getByTestId(`terminal-profile-row-${id}`)
.evaluate((element) => element.querySelector("svg path")?.getAttribute("d") ?? null);
const guessedSettingsPath = await settingsIconPath(guessed.id);
const explicitSettingsPath = await settingsIconPath(explicit.id);
expect(explicitMenuPath).not.toBeNull();
expect(explicitSettingsPath).not.toBeNull();
expect.soft(guessedMenuPath, "New tab Cursor profile icon").toBe(explicitMenuPath);
expect.soft(guessedSettingsPath, "Settings Cursor profile icon").toBe(explicitSettingsPath);
} finally {
await profileSeed.restore();
}
});
test("tab bar shows action buttons per pane", async ({ page }) => {
await gotoWorkspace(page, workspace.workspaceId);
await assertSingleNewTabButton(page);
@@ -0,0 +1,78 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect, type Page } from "../support/fixtures";
import { launchAgent, cleanupRewindFlow, type AgentHandle } from "../support/helpers/rewind-flow";
import { submitMessage } from "../support/helpers/composer";
const binary = process.env.OPENCODE_TEST_BINARY ?? "opencode";
test.use({
e2eDaemonConfig: {
version: 1,
agents: { providers: { opencode: { enabled: true, command: [binary] } } },
},
});
async function selectMedium(page: Page) {
await page
.getByRole("button", { name: /^Select thinking option/ })
.filter({ visible: true })
.click();
await page.getByText("Medium", { exact: true }).filter({ visible: true }).click();
await expect(
page
.getByRole("button", { name: "Select thinking option (Medium)", exact: true })
.filter({ visible: true }),
).toBeVisible();
}
async function selectLongcat(page: Page) {
await page
.getByRole("button", { name: /^Select model \(/ })
.filter({ visible: true })
.click();
await page
.getByText("LongCat 2.5 Preview Free", { exact: true })
.filter({ visible: true })
.click();
}
async function askForReply(handle: AgentHandle) {
await submitMessage(handle.page, "Reply exactly OK. Do not use tools.");
return handle.client.waitForFinish(handle.agentId, 120_000);
}
test("switching an existing OpenCode session clears unsupported thinking", async ({
page,
}, testInfo) => {
test.setTimeout(240_000);
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-variant-ui-"));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
cwd,
provider: "opencode",
mode: "full-access",
providerConfig: { model: "opencode/space-bunny-free" },
});
await selectMedium(page);
expect((await askForReply(handle)).status).toBe("idle");
await selectLongcat(page);
await expect(
page.getByRole("button", { name: /^Select thinking option/ }).filter({ visible: true }),
).toHaveCount(0);
const finish = await askForReply(handle);
const screenshot = testInfo.outputPath("model-switch.png");
await page.screenshot({ path: screenshot });
await testInfo.attach("After switching to Longcat", {
path: screenshot,
contentType: "image/png",
});
expect(finish.status).toBe("idle");
expect(finish.final?.lastError).toBeFalsy();
await expect(
page.getByTestId("assistant-message").filter({ visible: true }).last(),
).toContainText("OK");
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
@@ -14,6 +14,7 @@ import {
expectConflictPreservesDraft,
discardTitle,
returnAndReopenSettings,
returnToPluginsPage,
expectSettingsUnavailable,
} from "../support/helpers/plugin-settings";
@@ -43,6 +44,11 @@ test("another client's save preserves a conflicting draft", async ({ page }) =>
await discardTitle(page);
});
test("Back to plugins returns from a plugin settings screen", async ({ page }) => {
await openDisplaySettings(page);
await returnToPluginsPage(page);
});
test.describe("compact plugin settings", () => {
test.use({ viewport: { width: 390, height: 844 } });
@@ -2,6 +2,7 @@ import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
import { openSettingsSection } from "../support/helpers/settings";
@@ -120,3 +121,75 @@ test("applies a contributed theme and falls back when its plugin is gone", async
await rm(directory, { recursive: true, force: true });
}
});
const MANY_THEMES_PLUGIN_ID = "plugin-theme-many-e2e";
const MANY_THEMES_COUNT = 40;
const LAST_THEME_NAME = `Pack theme ${MANY_THEMES_COUNT}`;
const MANY_THEMES_SOURCE = `export default function contribute(plugin) {
for (let index = 1; index <= ${MANY_THEMES_COUNT}; index += 1) {
plugin.addTheme({
id: "pack-" + index,
name: "Pack theme " + index,
appearance: "dark",
colors: {
background: "#1e1e2e",
foreground: "#cdd6f4",
raised: "#313244",
control: "#45475a",
border: "#45475a",
accent: "#cba6f7",
mutedForeground: "#a6adc8",
ring: "#6c7086",
},
});
}
return () => {};
}`;
async function scrollThemeMenuToLastTheme(page: Page) {
const firstTheme = page.getByText("Pack theme 1", { exact: true });
await expect(firstTheme).toBeVisible({ timeout: 30_000 });
await firstTheme.hover();
await page.mouse.wheel(0, 4000);
await expect(page.getByText(LAST_THEME_NAME, { exact: true })).toBeInViewport({ ratio: 1 });
}
test("scrolls to the last theme when a plugin contributes more themes than fit", async ({
page,
}, testInfo) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-many-e2e-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previousConfig = await client.getDaemonConfig();
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: MANY_THEMES_PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.ts"), MANY_THEMES_SOURCE);
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await page.setViewportSize({ width: 1280, height: 720 });
await page.goto("/settings");
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await openSettingsSection(page, "appearance");
await page.getByLabel("Theme: System", { exact: true }).click();
await scrollThemeMenuToLastTheme(page);
await page.screenshot({
path: testInfo.outputPath("plugin-theme-picker-scrolled.png"),
animations: "disabled",
});
await page.getByText(LAST_THEME_NAME, { exact: true }).click();
await expect(page.getByLabel(`Theme: ${LAST_THEME_NAME}`, { exact: true })).toBeVisible();
} finally {
await client.removePlugin(MANY_THEMES_PLUGIN_ID).catch(() => undefined);
await client
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
.catch(() => undefined);
await client.close().catch(() => undefined);
await rm(directory, { recursive: true, force: true });
}
});
@@ -119,6 +119,25 @@ async function exerciseProviderSettingsStack(page: Page) {
}
test.describe("provider settings overlay stack", () => {
test("provider model update age says just now", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "provider-relative-time-",
title: "Provider relative time",
});
try {
await page.clock.install({ time: Date.now() });
await page.setViewportSize(MOBILE_VIEWPORT);
await openAgentRoute(page, session);
await expectComposerVisible(page);
await openProviderSettingsFromModelSelector(page);
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
timeout: 30_000,
});
} finally {
await session.cleanup();
}
});
test("provider settings covers the desktop model selector without closing it", async ({
page,
}) => {
@@ -1,152 +0,0 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { installProviderUsageFixture } from "../support/helpers/provider-usage";
import { getServerId } from "../support/helpers/server-id";
import { openSettingsHostSection } from "../support/helpers/settings";
test.describe("provider usage settings", () => {
test("renders every provider returned by the daemon usage RPC", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usageFixture = await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "claude",
displayName: "Claude",
status: "available",
planLabel: "Max 20x",
windows: [{ id: "session", label: "Session", usedPct: 7 }],
},
{
providerId: "codex",
displayName: "Codex",
status: "available",
planLabel: "Pro 20x",
windows: [{ id: "weekly", label: "Weekly", usedPct: 29 }],
},
{
providerId: "glm",
displayName: "GLM coding plan",
status: "available",
planLabel: "GLM coding plan",
sourceLabel: "OpenUsage 0.6.27",
windows: [
{ id: "biweekly", label: "Biweekly", usedPct: 23 },
{ id: "daily", label: "Daily", remainingPct: 30 },
],
balances: [
{ id: "credits", label: "Credits", remaining: 1234, unit: "credits" },
{ id: "extra", label: "Extra usage", used: 5, limit: 20, unit: "usd" },
],
details: [{ id: "valid", label: "Valid until", value: "2026-12-31" }],
},
],
},
]);
await gotoAppShell(page);
await openSettings(page);
expect(usageFixture.requestCount()).toBe(0);
await openSettingsHostSection(page, serverId, "usage");
await usageFixture.waitForRequestCount(1);
const card = page.getByTestId("provider-usage-card");
await expect(card).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("Claude", { exact: true })).toBeVisible();
await expect(card.getByText("Codex", { exact: true })).toBeVisible();
await expect(card.getByText("GLM coding plan", { exact: true }).first()).toBeVisible();
await expect(card.getByText("Biweekly", { exact: true })).toBeVisible();
await expect(card.getByText("Daily", { exact: true })).toBeVisible();
await expect(card.getByText("70%")).toBeVisible();
await expect(card.getByText("Credits", { exact: true })).toBeVisible();
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
await expect(card.getByText("Extra usage", { exact: true })).toBeVisible();
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
await expect(card.getByText("Valid until", { exact: true })).toBeVisible();
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
await expect(card.getByText(/OpenUsage 0\.6\.27/)).toBeVisible();
});
test("refresh invalidates and refetches usage", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usageFixture = await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "glm",
displayName: "GLM coding plan",
status: "available",
planLabel: "GLM coding plan",
windows: [{ id: "biweekly", label: "Biweekly", usedPct: 23 }],
},
],
},
{
fetchedAt: "2026-06-19T00:01:00.000Z",
providers: [
{
providerId: "glm",
displayName: "GLM coding plan",
status: "available",
planLabel: "GLM coding plan",
windows: [{ id: "biweekly", label: "Biweekly", usedPct: 64 }],
},
],
},
]);
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
await usageFixture.waitForRequestCount(1);
await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await usageFixture.waitForRequestCount(2);
expect(usageFixture.requestCount()).toBe(2);
await expect(page.getByText("64%")).toBeVisible();
});
test("one provider error does not collapse the usage list", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "claude",
displayName: "Claude",
status: "error",
planLabel: null,
windows: [],
error: "Claude auth expired",
},
{
providerId: "codex",
displayName: "Codex",
status: "available",
planLabel: "Pro 20x",
windows: [{ id: "weekly", label: "Weekly", usedPct: 71 }],
},
],
},
]);
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
const card = page.getByTestId("provider-usage-card");
await expect(card).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("Error", { exact: true })).toBeVisible();
await expect(card.getByText("Claude auth expired", { exact: true })).toBeVisible();
await expect(card.getByText("Codex", { exact: true })).toBeVisible();
await expect(card.getByText("71%")).toBeVisible();
});
});
@@ -1,113 +0,0 @@
import { expect, test, type Page } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { installProviderUsageFixture } from "../support/helpers/provider-usage";
const MOBILE_VIEWPORT = { width: 390, height: 844 };
async function openMockAgent(page: Page) {
await page.setViewportSize(MOBILE_VIEWPORT);
const session = await seedMockAgentWorkspace({
repoPrefix: "provider-usage-tooltip-",
title: "Provider usage tooltip e2e",
initialPrompt: "emit 1 coalesced agent stream update for provider usage tooltip.",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 });
return session;
}
test.describe("provider usage tooltip", () => {
test("fetches usage when the context tooltip opens and renders the active provider", async ({
page,
}) => {
test.setTimeout(180_000);
const usageFixture = await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "mock",
displayName: "Mock provider",
status: "available",
planLabel: "Test plan",
windows: [
{
id: "session",
label: "Session",
usedPct: 42,
remainingPct: 58,
resetsAt: "2026-06-19T05:00:00.000Z",
},
],
},
],
},
]);
const session = await openMockAgent(page);
try {
expect(usageFixture.requestCount()).toBe(0);
await page.getByTestId("context-window-meter").hover();
await usageFixture.waitForRequestCount(1);
await expect(page.getByText("Mock provider", { exact: true })).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText("Test plan")).toBeVisible();
await expect(page.getByText("Session", { exact: true })).toBeVisible();
await expect(page.getByText("42%")).toBeVisible();
} finally {
await session.cleanup();
}
});
test("refreshes usage again each time the tooltip is shown", async ({ page }) => {
test.setTimeout(180_000);
const usageFixture = await installProviderUsageFixture(page, [
{
fetchedAt: "2026-06-19T00:00:00.000Z",
providers: [
{
providerId: "mock",
displayName: "Mock provider",
status: "available",
planLabel: "Test plan",
windows: [{ id: "session", label: "Session", usedPct: 41 }],
},
],
},
{
fetchedAt: "2026-06-19T00:01:00.000Z",
providers: [
{
providerId: "mock",
displayName: "Mock provider",
status: "available",
planLabel: "Test plan",
windows: [{ id: "session", label: "Session", usedPct: 64 }],
},
],
},
]);
const session = await openMockAgent(page);
try {
const meter = page.getByTestId("context-window-meter");
await meter.hover();
await usageFixture.waitForRequestCount(1);
await expect(page.getByText("41%")).toBeVisible({ timeout: 10_000 });
await page.mouse.move(0, 0);
await expect(page.getByText("Mock provider", { exact: true })).toHaveCount(0);
await meter.hover();
await usageFixture.waitForRequestCount(2);
expect(usageFixture.requestCount()).toBe(2);
await expect(page.getByText("64%")).toBeVisible();
} finally {
await session.cleanup();
}
});
});
@@ -0,0 +1,118 @@
import { expect, test, type Page } from "../support/fixtures";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { buildSchedulesRoute } from "../../src/utils/host-routes";
interface ScheduleSeedClient {
scheduleCreate(input: {
prompt: string;
name?: string;
cadence: { type: "cron"; expression: string };
target: {
type: "new-agent";
config: {
provider: "mock";
cwd: string;
model: string;
modeId: string;
title: string;
};
};
runOnCreate: boolean;
}): Promise<{ schedule: { id: string; createdAt: string } | null; error: string | null }>;
schedulePause(input: { id: string }): Promise<{ error: string | null }>;
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
}
interface SeededSchedule {
id: string;
createdAt: number;
}
async function seedPausedSchedule(
workspace: SeededWorkspace,
name: string,
): Promise<SeededSchedule> {
const client = workspace.client as unknown as ScheduleSeedClient;
const result = await client.scheduleCreate({
prompt: "Say hello from the scheduled agent.",
name,
cadence: { type: "cron", expression: "0 9 * * *" },
target: {
type: "new-agent",
config: {
provider: "mock",
cwd: workspace.repoPath,
model: "ten-second-stream",
modeId: "load-test",
title: name,
},
},
runOnCreate: false,
});
if (!result.schedule) {
throw new Error(result.error ?? "Failed to seed schedule");
}
const { id, createdAt } = result.schedule;
const paused = await client.schedulePause({ id });
if (paused.error) {
throw new Error(paused.error);
}
return { id, createdAt: Date.parse(createdAt) };
}
async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Promise<void> {
const client = workspace.client as unknown as ScheduleSeedClient;
const result = await client.scheduleDelete({ id });
if (result.error) {
throw new Error(result.error);
}
}
/** Opens Schedules with the browser clock frozen at `time`, so the page cannot age it. */
async function openSchedulesAt(page: Page, time: number): Promise<void> {
await page.clock.install({ time });
await page.goto(buildSchedulesRoute());
}
async function letTimePass(page: Page, duration: string): Promise<void> {
await page.clock.fastForward(duration);
}
async function expectScheduleRowText(page: Page, scheduleId: string, text: string): Promise<void> {
await expect(page.getByTestId(`schedule-row-${scheduleId}`)).toContainText(text, {
timeout: 30_000,
});
}
test.describe("Schedule relative timestamps", () => {
const cleanupTasks: Array<() => Promise<void>> = [];
test.afterEach(async () => {
const failures: unknown[] = [];
for (const cleanup of cleanupTasks.toReversed()) {
try {
await cleanup();
} catch (error) {
failures.push(error);
}
}
cleanupTasks.length = 0;
if (failures.length > 0) {
throw new AggregateError(failures, "Test cleanup failed");
}
});
test("an idle schedule row keeps its created age current", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "schedule-relative-time-", git: false });
cleanupTasks.push(() => workspace.cleanup());
const schedule = await seedPausedSchedule(workspace, `Relative time ${Date.now()}`);
cleanupTasks.push(() => deleteSeededSchedule(workspace, schedule.id));
await openSchedulesAt(page, schedule.createdAt);
await expectScheduleRowText(page, schedule.id, "Created just now");
await letTimePass(page, "03:00");
await expectScheduleRowText(page, schedule.id, "Created 3m ago");
});
});
@@ -2,6 +2,7 @@ import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import { TEST_HOST_LABEL } from "../support/helpers/daemon-registry";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import { getServerId } from "../support/helpers/server-id";
import {
expectSettingsHeader,
@@ -18,6 +19,12 @@ import {
expectRetiredSidebarSectionsAbsent,
expectHostPageVisible,
seedSavedSettingsHosts,
selectSettingsHost,
expectHostRejectedWithReAddGuidance,
expectNoHostPasswordControls,
removeHostFromHostPage,
addDirectHostFromSettings,
expectHostOnlineWithoutError,
} from "../support/helpers/settings";
test.describe("Settings host page", () => {
@@ -89,6 +96,47 @@ test.describe("Settings host page", () => {
await expect(updateButton).toBeEnabled();
});
test("a host rejected for its password reconnects after being removed and added again with it", async ({
page,
}) => {
const password = "e2e host password";
const daemon = await startIsolatedHostDaemon("srv_e2e_password_host", {
environment: { ...process.env, PASEO_PASSWORD: password },
});
try {
await seedSavedSettingsHosts(page, [
{
serverId: getServerId(),
label: TEST_HOST_LABEL,
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
},
{
serverId: daemon.serverId,
label: "Password host",
endpoint: `127.0.0.1:${daemon.port}`,
},
]);
await page.reload();
await openSettings(page);
await selectSettingsHost(page, daemon.serverId);
await openHostSection(page, daemon.serverId, "host");
await test.step("the host page shows the reason and the re-add guidance, with no password UI", async () => {
await expectHostRejectedWithReAddGuidance(page, "Password required");
await expectNoHostPasswordControls(page);
});
await test.step("removing the host and adding it again with the password brings it online", async () => {
await removeHostFromHostPage(page, daemon.serverId);
await addDirectHostFromSettings(page, { host: "127.0.0.1", port: daemon.port, password });
await openHostSection(page, daemon.serverId, "host");
await expectHostOnlineWithoutError(page);
});
} finally {
await daemon.close();
}
});
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
page,
}) => {
@@ -1,4 +1,5 @@
import path from "node:path";
import type { Page, TestInfo } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
@@ -11,6 +12,7 @@ import {
import { seedWorkspace } from "../support/helpers/seed-client";
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
import { getServerId } from "../support/helpers/server-id";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { escapeRegex } from "../support/helpers/regex";
import { openFilesPanel } from "../support/helpers/workspace-tabs";
@@ -108,7 +110,207 @@ async function withPaseoOwnedWorktree(
}
}
async function readCachedRowIds(
page: Page,
rowKind: "workspace" | "checkpoint",
): Promise<string[]> {
return page.evaluate(
async ({ serverId, kind }) => {
const database = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open("paseo-replica-row-store");
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () => reject(request.error));
});
try {
return await new Promise<string[]>((resolve, reject) => {
const transaction = database.transaction("rows", "readonly");
const request = transaction.objectStore("rows").getAll();
request.onsuccess = () =>
resolve(
request.result
.filter(
(row: { serverId: string; kind: string }) =>
row.serverId === serverId && row.kind === kind,
)
.map((row: { id: string }) => row.id)
.sort(),
);
request.addEventListener("error", () => reject(request.error));
});
} finally {
database.close();
}
},
{ serverId: getServerId(), kind: rowKind },
);
}
async function simulateDamagedLegacyDirectoryCache(
page: Page,
workspaceIdToRemove: string,
): Promise<void> {
// Stop the app before altering storage so no pending app write can repair the fixture.
const fixtureUrl = new URL("/__directory_cache_fixture", page.url()).href;
await page.route(fixtureUrl, (route) =>
route.fulfill({ contentType: "text/html", body: "<html><body>Cache fixture</body></html>" }),
);
await page.goto(fixtureUrl);
await page.evaluate(
async ({ serverId, missingWorkspaceId }) => {
const database = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open("paseo-replica-row-store");
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () => reject(request.error));
});
try {
await new Promise<void>((resolve, reject) => {
const transaction = database.transaction("rows", "readwrite");
const store = transaction.objectStore("rows");
const request = store.getAll();
request.onsuccess = () => {
const rows = request.result as Array<{
serverId: string;
kind: string;
id: string;
payload: string;
}>;
const checkpoint = rows.find(
(row) => row.serverId === serverId && row.kind === "checkpoint",
);
if (!checkpoint) {
transaction.abort();
return;
}
const stored = JSON.parse(checkpoint.payload);
// Accept either writer so this fixture also reproduces on the unfixed client.
const cursors = stored.version === 1 ? stored.cursors : stored;
if (!cursors.workspaces?.generation) {
transaction.abort();
return;
}
store.put({ ...checkpoint, payload: JSON.stringify(cursors) });
for (const row of rows) {
if (
row.serverId === serverId &&
row.kind === "workspace" &&
row.id === missingWorkspaceId
)
store.delete([serverId, row.kind, row.id]);
}
};
transaction.addEventListener("complete", () => resolve());
transaction.addEventListener("abort", () =>
reject(transaction.error ?? new Error("Missing current directory checkpoint")),
);
transaction.addEventListener("error", () => reject(transaction.error));
});
} finally {
database.close();
}
},
{ serverId: getServerId(), missingWorkspaceId: workspaceIdToRemove },
);
expect(await readCachedRowIds(page, "workspace")).not.toContain(workspaceIdToRemove);
}
async function holdHostResponsesUntilCacheIsVisible(page: Page): Promise<() => void> {
let release!: () => void;
const ready = new Promise<void>((resolve) => {
release = resolve;
});
await page.routeWebSocket(new RegExp(`:${getE2EDaemonPort()}(?:/|$)`), (socket) => {
const server = socket.connectToServer();
server.onMessage(async (message) => {
await ready;
socket.send(message);
});
});
return release;
}
async function createPinnedSiblingWorkspace(
project: Awaited<ReturnType<typeof seedWorkspace>>,
): Promise<string> {
const sibling = await project.client.createWorkspace({
source: { kind: "directory", path: project.repoPath, projectId: project.projectId },
title: "Pinned sibling",
});
if (!sibling.workspace) throw new Error(sibling.error ?? "Sibling workspace was not created");
await project.client.setWorkspacePinned(sibling.workspace.id, true);
return sibling.workspace.id;
}
async function expectSidebarWorkspaces(page: Page, workspaceIds: string[]): Promise<void> {
for (const workspaceId of workspaceIds) {
await waitForSidebarWorkspace(page, workspaceId);
}
}
async function cacheWorkspaces(page: Page, workspaceIds: string[]): Promise<void> {
await gotoAppShell(page);
await expectSidebarWorkspaces(page, workspaceIds);
await expect.poll(() => readCachedRowIds(page, "workspace")).toEqual(workspaceIds);
}
async function reopenDamagedCacheBeforeHostResponds(
page: Page,
missingWorkspaceId: string,
): Promise<() => void> {
await simulateDamagedLegacyDirectoryCache(page, missingWorkspaceId);
const releaseHost = await holdHostResponsesUntilCacheIsVisible(page);
await gotoAppShell(page);
return releaseHost;
}
async function expectDamagedCacheHydrated(
page: Page,
retainedWorkspaceId: string,
missingWorkspaceId: string,
): Promise<void> {
await waitForSidebarWorkspace(page, retainedWorkspaceId);
await expect(page.getByTestId(getWorkspaceRowTestId(missingWorkspaceId))).toHaveCount(0);
await expect.poll(() => readCachedRowIds(page, "checkpoint")).toEqual([]);
}
async function expectRecoveredWorkspacesAfterReload(
page: Page,
workspaceIds: string[],
testInfo: TestInfo,
): Promise<void> {
await expectSidebarWorkspaces(page, workspaceIds);
await expect.poll(() => readCachedRowIds(page, "workspace")).toEqual(workspaceIds);
await page.reload();
await expectSidebarWorkspaces(page, workspaceIds);
await page.screenshot({ path: testInfo.outputPath("recovered-workspaces.png") });
await testInfo.attach("recovered-workspaces", {
path: testInfo.outputPath("recovered-workspaces.png"),
contentType: "image/png",
});
}
test.describe("Sidebar workspace list", () => {
test("recovers missing workspaces from a damaged legacy cache after reload", async ({
page,
}, testInfo) => {
const project = await seedWorkspace({ repoPrefix: "directory-cache-recovery-" });
try {
const siblingId = await createPinnedSiblingWorkspace(project);
const ids = [project.workspaceId, siblingId].sort();
await test.step("cache both workspaces", () => cacheWorkspaces(page, ids));
const releaseHost = await test.step("reopen damaged cache before host responds", () =>
reopenDamagedCacheBeforeHostResponds(page, project.workspaceId));
await test.step("keep cached workspace and discard stale checkpoint", () =>
expectDamagedCacheHydrated(page, siblingId, project.workspaceId));
releaseHost();
await test.step("recover missing workspace and retain both after reload", () =>
expectRecoveredWorkspacesAfterReload(page, ids, testInfo));
} finally {
await project.cleanup();
}
});
test("project with GitHub remote shows its selected folder name in sidebar", async ({ page }) => {
const workspace = await seedWorkspace({
repoPrefix: "sidebar-remote-",
@@ -0,0 +1,149 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openSubagentsTrack, seedParentWithSubagent } from "../support/helpers/subagents";
const SETTINGS_KEY = "@paseo:app-settings";
async function openParentInRightPane(page: Page, parentId: string, workspaceId: string) {
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, { workspaceId, agentId: parentId });
await expect(page.getByTestId(`workspace-tab-agent_${parentId}`)).toBeVisible();
await page.getByTestId("workspace-pane-main").getByTestId("workspace-new-tab-button").click();
await page.getByTestId("workspace-new-tab-menu-agent").click();
await page.getByTestId(`workspace-tab-agent_${parentId}`).click();
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await page
.getByTestId("workspace-pane-main")
.getByTestId(`workspace-tab-agent_${parentId}`)
.click();
await page.keyboard.press("Meta+Alt+Shift+ArrowRight");
const left = page.getByTestId("workspace-pane-main");
const right = page
.locator('[data-testid^="workspace-pane-"]')
.filter({ has: page.getByTestId(`workspace-tab-agent_${parentId}`) });
await expect(left).toBeVisible();
await expect(right).toBeVisible();
await expect(right).not.toHaveAttribute("data-testid", "workspace-pane-main");
await left.locator('[data-testid^="workspace-tab-draft_"]').click();
return { left, right };
}
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
});
});
test("a subagent opened from a right-pane parent stays with its parent when left has focus", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "subagent-origin-pane-" });
try {
const pair = await seedParentWithSubagent(workspace, {
parentTitle: "Right parent",
childTitle: "Child agent",
});
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
await openSubagentsTrack(page);
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("the side preference opens a right-pane parent's subagent in the side pane", async ({
page,
}) => {
await page.addInitScript((key) => {
localStorage.setItem(key, JSON.stringify({ openInSidePane: { subagents: true } }));
}, SETTINGS_KEY);
const workspace = await seedWorkspace({ repoPrefix: "subagent-side-preference-" });
try {
const pair = await seedParentWithSubagent(workspace, {
parentTitle: "Right parent",
childTitle: "Child agent",
});
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
await openSubagentsTrack(page);
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
const childPane = page
.locator('[data-testid^="workspace-pane-"]')
.filter({ has: page.getByTestId(`workspace-tab-agent_${pair.child.id}`) });
await expect(childPane).toBeVisible();
expect(await childPane.getAttribute("data-testid")).not.toBe(
await right.getAttribute("data-testid"),
);
await expect(childPane).not.toHaveAttribute("data-testid", "workspace-pane-main");
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
test("opening an already-tabbed subagent reveals its left tab without a duplicate", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "subagent-existing-tab-" });
try {
const pair = await seedParentWithSubagent(workspace, {
parentTitle: "Right parent",
childTitle: "Child agent",
});
const { left, right } = await openParentInRightPane(page, pair.parent.id, pair.workspaceId);
await openSubagentsTrack(page);
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
await page.keyboard.press("Meta+Alt+Shift+ArrowLeft");
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
await right.getByTestId(`workspace-tab-agent_${pair.parent.id}`).click();
await openSubagentsTrack(page);
await page.getByTestId(`subagents-track-row-${pair.child.id}`).click();
await expect(left.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toBeVisible();
await expect(right.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(0);
await expect(page.getByTestId(`workspace-tab-agent_${pair.child.id}`)).toHaveCount(1);
} finally {
await workspace.cleanup();
}
});
test("a preferred chat file link still opens beside its right-pane agent", async ({ page }) => {
const target = "linked.ts:3";
const workspace = await seedMockAgentWorkspace({
repoPrefix: "chat-file-origin-pane-",
title: "File link parent",
initialPrompt: [
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
"Return JSON only with fields 'title' and 'branch'.",
"",
"<user-prompt>",
`Open \`${target}\` now`,
"</user-prompt>",
].join("\n"),
});
try {
await writeFile(path.join(workspace.cwd, "linked.ts"), "one\ntwo\nthree\n", "utf8");
const { left, right } = await openParentInRightPane(
page,
workspace.agentId,
workspace.workspaceId,
);
const fileLink = right.getByText(target, { exact: true });
await expect(fileLink).toBeVisible({ timeout: 15_000 });
await fileLink.click();
await expect(right.getByTestId("workspace-tab-file_linked.ts")).toBeVisible();
await expect(left.getByTestId("workspace-tab-file_linked.ts")).toHaveCount(0);
} finally {
await workspace.cleanup();
}
});
@@ -0,0 +1,219 @@
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, test, type Page } from "../support/fixtures";
import { expectComposerVisible, submitMessage } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
const REPORT_ID = "fixture:fixture-account";
// Two hours reads "2h ago" for an hour, so the assertion cannot race the clock.
function twoHoursAgo(): string {
return new Date(Date.now() - 2 * 60 * 60_000).toISOString();
}
function agentEntry(usedPct: number, fetchedAt = "2026-01-01T00:00:00.000Z"): UsageReportEntry {
return {
id: REPORT_ID,
account: { label: "Fixture account" },
fetchedAt,
sourceId: "fixture",
sourceLabel: "Fixture plan",
icon: '<svg viewBox="0 0 24 24"><rect width="24" height="24" fill="currentColor"/></svg>',
report: {
status: "available",
planLabel: "Test plan",
windows: [
{ id: "session", label: "Session", usedPct: 3 },
{ id: "weekly", label: "Weekly", usedPct, headline: true },
],
},
};
}
async function openMockAgent(page: Page) {
const session = await seedMockAgentWorkspace({
repoPrefix: "usage-composer-pill-",
title: "Usage composer pill e2e",
initialPrompt: "emit 1 coalesced agent stream update for usage composer pill.",
});
await openAgentRoute(page, session);
await expectComposerVisible(page);
return session;
}
test.describe("usage composer pill", () => {
test("opens the cached report without a request and shows when it was fetched", async ({
page,
}) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
agentReportIds: [REPORT_ID],
lists: [[agentEntry(42, twoHoursAgo())]],
});
const session = await openMockAgent(page);
try {
const pill = page.getByTestId("usage-composer-pill");
await expect(pill).toContainText("42%", { timeout: 30_000 });
expect(usage.agentRequests()[0]).toEqual({ agentId: session.agentId });
expect(usage.listRequests()).toEqual([{ forceRefresh: false, reportIds: [REPORT_ID] }]);
await pill.click();
const popover = page.getByTestId("usage-composer-popover");
await expect(popover.getByText("Fixture plan", { exact: true })).toBeVisible();
await expect(popover.getByText("Test plan")).toBeVisible();
await expect(popover.getByText("42%")).toBeVisible();
await popover.getByTestId("usage-refresh").hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
await expect(popover.getByTestId("usage-freshness")).toHaveCount(0);
expect(usage.listRequests()).toHaveLength(1);
} finally {
await session.cleanup();
}
});
test("refreshes only its report from the popover", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
agentReportIds: [REPORT_ID],
lists: [[agentEntry(42, twoHoursAgo())], () => [agentEntry(64, new Date().toISOString())]],
});
const session = await openMockAgent(page);
try {
const pill = page.getByTestId("usage-composer-pill");
await expect(pill).toContainText("42%", { timeout: 30_000 });
await pill.click();
const popover = page.getByTestId("usage-composer-popover");
await expect(popover.getByText("42%")).toBeVisible();
await popover.getByTestId("usage-refresh").click();
await usage.waitForListRequests(2);
expect(usage.listRequests()).toEqual([
{ forceRefresh: false, reportIds: [REPORT_ID] },
{ forceRefresh: true, reportIds: [REPORT_ID] },
]);
await expect(popover.getByText("64%")).toBeVisible();
await expect(pill).toContainText("64%");
await expect(popover.getByTestId("usage-refresh")).toBeEnabled();
// Pressing closes the tooltip; it reopens on the next hover.
await popover.getByText("Fixture plan", { exact: true }).hover();
await popover.getByTestId("usage-refresh").hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated just now");
expect(usage.listRequests()).toHaveLength(2);
} finally {
await session.cleanup();
}
});
test("keeps the previous report and says so when a refresh fails", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
agentReportIds: [REPORT_ID],
lists: [
[agentEntry(42, twoHoursAgo())],
{ error: "Usage source timed out" },
() => [agentEntry(64, new Date().toISOString())],
],
});
const session = await openMockAgent(page);
try {
const pill = page.getByTestId("usage-composer-pill");
await expect(pill).toContainText("42%", { timeout: 30_000 });
await pill.click();
const popover = page.getByTestId("usage-composer-popover");
const refresh = popover.getByTestId("usage-refresh");
await refresh.click();
await usage.waitForListRequests(2);
await expect(popover.getByTestId("usage-refresh-error")).toHaveText(
"Unable to refresh usage",
);
await expect(popover.getByText("42%")).toBeVisible();
await expect(pill).toContainText("42%");
await refresh.click();
await usage.waitForListRequests(3);
await expect(popover.getByText("64%")).toBeVisible();
await expect(popover.getByTestId("usage-refresh-error")).toHaveCount(0);
} finally {
await session.cleanup();
}
});
test("prints the freshness on the card where there is no hover", async ({ page }) => {
test.setTimeout(180_000);
await page.setViewportSize({ width: 390, height: 844 });
const usage = await installUsageReportsFixture(page, {
agentReportIds: [REPORT_ID],
lists: [[agentEntry(42, twoHoursAgo())]],
});
const session = await openMockAgent(page);
try {
const pill = page.getByTestId("usage-composer-pill");
await expect(pill).toContainText("42%", { timeout: 30_000 });
await pill.click();
const popover = page.getByTestId("usage-composer-popover");
await expect(popover.getByTestId("usage-freshness")).toHaveText("Updated 2h ago");
expect(usage.listRequests()).toHaveLength(1);
} finally {
await session.cleanup();
}
});
test("re-resolves the report ID after a turn and reuses the cached report", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
agentReportIds: [REPORT_ID],
lists: [[agentEntry(42)]],
});
const session = await openMockAgent(page);
try {
const pill = page.getByTestId("usage-composer-pill");
await expect(pill).toContainText("42%", { timeout: 30_000 });
const requestsBeforeTurn = usage.agentRequests().length;
const listRequestsBeforeTurn = usage.listRequests().length;
await submitMessage(page, "emit 1 coalesced agent stream update for usage composer pill.");
await usage.waitForAgentRequests(requestsBeforeTurn + 1);
expect(usage.agentRequests().at(-1)).toEqual({
agentId: session.agentId,
});
await expect(pill).toContainText("42%");
expect(usage.listRequests()).toHaveLength(listRequestsBeforeTurn);
} finally {
await session.cleanup();
}
});
test("is hidden when the agent has no usage report", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, { agentReportIds: [null] });
const session = await openMockAgent(page);
try {
await usage.waitForAgentRequests(1);
await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0);
expect(usage.listRequests()).toHaveLength(0);
} finally {
await session.cleanup();
}
});
test("is hidden on a host without usage sources", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
usageSources: false,
agentReportIds: [REPORT_ID],
lists: [[agentEntry(42)]],
});
const session = await openMockAgent(page);
try {
await expect(page.getByTestId("context-window-meter")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0);
expect(usage.agentRequests()).toHaveLength(0);
} finally {
await session.cleanup();
}
});
});
@@ -0,0 +1,156 @@
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
// Two hours reads "2h ago" for an hour, so the assertion cannot race the clock.
function twoHoursAgo(): string {
return new Date(Date.now() - 2 * 60 * 60_000).toISOString();
}
test.describe("usage screen", () => {
test("opens from the sidebar and groups reports under their host", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usage = await installUsageReportsFixture(page, {
lists: [
[
{
id: "alpha:a",
account: {},
fetchedAt: twoHoursAgo(),
sourceId: "alpha",
sourceLabel: "Alpha plan",
report: {
status: "available",
windows: [{ id: "weekly", label: "Weekly", usedPct: 31, headline: true }],
},
},
{
id: "beta:b",
account: {},
fetchedAt: "2026-01-01T00:00:00.000Z",
sourceId: "beta",
sourceLabel: "Beta plan",
report: { status: "unavailable", windows: [] },
},
],
],
});
await gotoAppShell(page);
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
await expect(page).toHaveURL(/\/usage$/);
await usage.waitForListRequests(1);
const group = page.getByTestId(`usage-host-${serverId}`);
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(group.getByText("31%")).toBeVisible();
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
await group.getByTestId("usage-refresh").first().hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
await expect(group.getByTestId("usage-freshness")).toHaveCount(0);
});
test("refreshes one report from its card", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const beta: UsageReportEntry = {
id: "beta:b",
account: {},
fetchedAt: twoHoursAgo(),
sourceId: "beta",
sourceLabel: "Beta plan",
report: {
status: "available",
windows: [{ id: "weekly", label: "Weekly", usedPct: 12, headline: true }],
},
};
const alpha = (usedPct: number, fetchedAt: string): UsageReportEntry => ({
id: "alpha:a",
account: {},
fetchedAt,
sourceId: "alpha",
sourceLabel: "Alpha plan",
report: {
status: "available",
windows: [{ id: "weekly", label: "Weekly", usedPct, headline: true }],
},
});
const usage = await installUsageReportsFixture(page, {
lists: [
[alpha(31, twoHoursAgo()), beta],
() => [
alpha(58, new Date().toISOString()),
{ ...beta, fetchedAt: new Date().toISOString() },
],
],
});
await gotoAppShell(page);
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
const group = page.getByTestId(`usage-host-${serverId}`);
await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
const alphaRefresh = group.getByTestId("usage-refresh").first();
await alphaRefresh.click();
await usage.waitForListRequests(2);
expect(usage.listRequests()).toEqual([
{ forceRefresh: false, reportIds: undefined },
{ forceRefresh: true, reportIds: ["alpha:a"] },
]);
await expect(group.getByText("58%")).toBeVisible();
await expect(group.getByText("12%")).toBeVisible();
await group.getByTestId("usage-refresh").nth(1).hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated 2h ago");
await alphaRefresh.hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText("Updated just now");
});
test("shows the host once it connects after a cold load on a phone", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usage = await installUsageReportsFixture(page, {
lists: [
[
{
id: "alpha:a",
account: {},
fetchedAt: twoHoursAgo(),
sourceId: "alpha",
sourceLabel: "Alpha plan",
report: { status: "available", windows: [] },
},
],
],
});
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/usage");
await usage.waitForListRequests(1);
const group = page.getByTestId(`usage-host-${serverId}`);
await expect(group.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(group.getByTestId("usage-freshness")).toHaveText("Updated 2h ago");
});
test("tells the user to update a host without usage sources", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usage = await installUsageReportsFixture(page, { usageSources: false });
await gotoAppShell(page);
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
await expect(
page.getByTestId(`usage-host-${serverId}`).getByText("Update the host to see usage", {
exact: true,
}),
).toBeVisible({ timeout: 10_000 });
expect(usage.listRequests()).toHaveLength(0);
});
});
@@ -0,0 +1,136 @@
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
import { openSettingsHostSection } from "../support/helpers/settings";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
const ICON = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor"/></svg>';
function report(input: {
sourceId: string;
sourceLabel: string;
report: Partial<UsageReportEntry["report"]>;
}): UsageReportEntry {
return {
id: `${input.sourceId}:account`,
account: { label: input.sourceId === "alpha" ? "dev@example.com" : undefined },
fetchedAt: "2026-01-01T00:00:00.000Z",
sourceId: input.sourceId,
sourceLabel: input.sourceLabel,
icon: ICON,
report: {
status: "available",
windows: [],
...input.report,
},
};
}
test.describe("usage settings", () => {
test("renders every report returned by usage.list_reports", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usage = await installUsageReportsFixture(page, {
lists: [
[
report({
sourceId: "alpha",
sourceLabel: "Alpha plan",
report: {
planLabel: "Max",
windows: [{ id: "session", label: "Session", usedPct: 7, headline: true }],
},
}),
report({
sourceId: "beta",
sourceLabel: "Beta plan",
report: {
planLabel: "Coding plan",
windows: [
{ id: "biweekly", label: "Biweekly", usedPct: 23 },
{ id: "daily", label: "Daily", remainingPct: 30 },
],
balances: [
{ id: "credits", label: "Credits", remaining: 1234, unit: "credits" },
{ id: "extra", label: "Extra usage", used: 5, limit: 20, unit: "usd" },
],
details: [{ id: "valid", label: "Valid until", value: "2026-12-31" }],
},
}),
report({
sourceId: "gamma",
sourceLabel: "Gamma plan",
report: { status: "error", error: "Gamma auth expired" },
}),
],
],
});
await gotoAppShell(page);
await openSettings(page);
expect(usage.listRequests()).toHaveLength(0);
await openSettingsHostSection(page, serverId, "usage");
await usage.waitForListRequests(1);
const card = page.getByTestId("usage-card");
await expect(card.getByText("Alpha plan", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(card.getByText("dev@example.com", { exact: true })).toBeVisible();
await expect(card.getByText("Beta plan", { exact: true })).toBeVisible();
await expect(card.getByText("70%")).toBeVisible();
await expect(card.getByText("1,234 left", { exact: true })).toBeVisible();
await expect(card.getByText("$5.00 / $20.00", { exact: true })).toBeVisible();
await expect(card.getByText("2026-12-31", { exact: true })).toBeVisible();
await expect(card.getByText("Gamma auth expired", { exact: true })).toBeVisible();
});
test("refresh forces a fresh report", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const windows = (usedPct: number) => [{ id: "w", label: "Weekly", usedPct }];
const usage = await installUsageReportsFixture(page, {
lists: [
[
report({
sourceId: "alpha",
sourceLabel: "Alpha plan",
report: { windows: windows(23) },
}),
],
[
report({
sourceId: "alpha",
sourceLabel: "Alpha plan",
report: { windows: windows(64) },
}),
],
],
});
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
await expect(page.getByText("23%")).toBeVisible({ timeout: 10_000 });
await page.getByRole("button", { name: "Refresh", exact: true }).click();
await usage.waitForListRequests(2);
expect(usage.listRequests().at(-1)).toEqual({ forceRefresh: true });
await expect(page.getByText("64%")).toBeVisible();
});
test("asks to update a host without usage sources and never calls it", async ({ page }) => {
test.setTimeout(120_000);
const serverId = getServerId();
const usage = await installUsageReportsFixture(page, { usageSources: false });
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
await expect(
page.getByTestId("usage-card").getByText("Update the host to see usage", { exact: true }),
).toBeVisible({ timeout: 10_000 });
expect(usage.listRequests()).toHaveLength(0);
});
});
@@ -482,6 +482,50 @@ export async function expectModelRowSelected(
).toHaveAttribute("aria-selected", "true", { timeout: 30_000 });
}
function modelRow(page: Page, input: { provider: string; modelId: string }): Locator {
return pickerViewport(page).getByTestId(`model-row-${input.provider}-${input.modelId}`);
}
/**
* A model row's profile action is its own button. On web, a button inside the
* row's button is invalid HTML that React reports on every render.
*/
export async function expectModelRowProfileActionBesideRow(
page: Page,
input: { provider: string; modelId: string },
): Promise<void> {
const row = modelRow(page, input);
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(pickerViewport(page).locator("button button")).toHaveCount(0);
await expect(row.getByRole("button")).toHaveCount(0);
}
/** The hover-revealed plus on an unprofiled model row opens a seeded profile form. */
export async function expectCreateProfileFromModelRow(
page: Page,
input: { provider: string; modelId: string; modelLabel: string },
): Promise<void> {
await modelRow(page, input).hover();
await pickerViewport(page)
.getByTestId(`model-create-profile-${input.provider}-${input.modelId}`)
.click();
const modal = editModal(page);
await expect(modal).toBeVisible({ timeout: 30_000 });
await expect(modal.getByRole("textbox", { name: "Name", exact: true })).toHaveValue(
input.modelLabel,
);
await expect(modal.getByRole("button", { name: `Model (${input.modelLabel})` })).toBeVisible();
await modal.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(modal).toHaveCount(0, { timeout: 30_000 });
}
export async function selectModelRow(
page: Page,
input: { provider: string; modelId: string },
): Promise<void> {
await modelRow(page, input).click();
}
// ─── Cross-provider model search ───────────────────────────────────────────
export async function searchAllModels(page: Page, query: string): Promise<void> {
@@ -3,6 +3,7 @@ import { openSettings } from "./app";
import { openSettingsSection } from "./settings";
import { runWorkspaceActionFromCommandCenter } from "./command-center-workspace-actions";
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { loadSessionMessageReaders } from "./new-workspace";
export async function withStreamingMarkdownOutline(
run: (agent: MockAgentWorkspace) => Promise<void>,
@@ -83,7 +84,7 @@ export async function splitCurrentPanelRight(page: Page): Promise<void> {
export async function disableChatOutlineFromAppearance(page: Page): Promise<void> {
const timelineUrl = page.url();
await openSettings(page);
await openSettingsSection(page, "appearance");
await openSettingsSection(page, "chat");
await page.getByRole("switch", { name: "Chat outline" }).click();
await page.goto(timelineUrl);
}
@@ -229,3 +230,22 @@ async function requireBoundingBox(
}
return box;
}
export async function observePromptIndexRequests(page: Page) {
const frames = await loadSessionMessageReaders();
const agentIds: string[] = [];
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
const request = frames.client(payload);
if (request?.type === "agent.timeline.list_prompts.request") {
agentIds.push(request.agentId);
}
});
});
return {
async waitForRequestFor(agentId: string) {
await expect.poll(() => agentIds).toContain(agentId);
},
requestedAgentIds: () => [...new Set(agentIds)],
};
}
@@ -206,6 +206,12 @@ export async function createCreationScenario(page: Page) {
)
.toBe(count);
},
async agentIds() {
await requests.settled();
return (await project.client.fetchAgents()).entries
.filter(({ agent }) => agent.workspaceId === workspaceId)
.map(({ agent }) => agent.id);
},
async expectAgentTitle(title: string) {
await expect
.poll(
@@ -41,6 +41,13 @@ async function getAvailablePort(): Promise<number> {
});
}
export async function getAvailableHostDaemonPort(): Promise<number> {
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
let port = await getAvailablePort();
while (port === 6767 || port === 6768 || port === primaryPort) port = await getAvailablePort();
return port;
}
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
const deadline = Date.now() + 90_000;
let lastError: unknown = null;
@@ -81,9 +88,7 @@ export async function startIsolatedHostDaemon(
serverId: string,
options: IsolatedHostDaemonOptions = {},
): Promise<IsolatedHostDaemon> {
const primaryPort = Number(process.env.E2E_DAEMON_PORT ?? 0);
let port = await getAvailablePort();
while (port === 6767 || port === 6768 || port === primaryPort) port = await getAvailablePort();
const port = await getAvailableHostDaemonPort();
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) throw new Error("E2E_METRO_PORT is required to start an isolated host daemon");
@@ -3,6 +3,8 @@ import { expect, test as base, type Page } from "../fixtures";
import { gotoAppShell, openSettings } from "./app";
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
import { getServerId } from "./server-id";
import { expectAppRoute } from "./route-assertions";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
@@ -47,7 +49,7 @@ async function openPluginScreen(page: Page) {
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
}
export async function groupByWorkspace(page: Page) {
await page.getByRole("button", { name: "Group agents by", exact: true }).click();
await page.getByRole("button", { name: /^Group agents by:/ }).click();
await page.getByRole("menuitem", { name: "Workspace", exact: true }).click();
await expect(page.getByText("Grouped by workspace", { exact: true })).toBeVisible();
}
@@ -105,6 +107,13 @@ export async function returnAndReopenSettings(page: Page) {
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
}
export async function returnToPluginsPage(page: Page) {
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
await expectAppRoute(page, buildSettingsHostSectionRoute(getServerId(), "plugins"));
await expect(
page.getByRole("button", { name: "Actions for settings-example", exact: true }),
).toBeVisible();
}
export async function expectSettingsUnavailable(page: Page) {
await expect(
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
@@ -1,154 +0,0 @@
import type { Page } from "@playwright/test";
import type { ProviderUsage } from "@getpaseo/protocol/messages";
import { daemonWsRoutePattern } from "./daemon-port";
interface ProviderUsageFixturePayload {
fetchedAt: string;
providers: ProviderUsage[];
}
export interface ProviderUsageFixture {
requestCount(): number;
waitForRequestCount(count: number): Promise<void>;
}
type WebSocketMessage = string | Buffer;
function parseJson(message: WebSocketMessage): unknown {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
return null;
}
if (typeof maybeEnvelope.message !== "object") {
return null;
}
return maybeEnvelope.message as Record<string, unknown>;
}
function withProviderUsageFeature(message: WebSocketMessage): string | null {
const envelope = parseJson(message);
if (!envelope || typeof envelope !== "object") {
return null;
}
const maybeEnvelope = envelope as {
type?: unknown;
message?: {
type?: unknown;
payload?: Record<string, unknown>;
};
};
const payload = maybeEnvelope.message?.payload;
if (
maybeEnvelope.type !== "session" ||
maybeEnvelope.message?.type !== "status" ||
payload?.status !== "server_info"
) {
return null;
}
return JSON.stringify({
...maybeEnvelope,
message: {
...maybeEnvelope.message,
payload: {
...payload,
features: {
...(typeof payload.features === "object" && payload.features !== null
? payload.features
: {}),
providerUsageList: true,
},
},
},
});
}
export async function installProviderUsageFixture(
page: Page,
payloads: ProviderUsageFixturePayload[],
): Promise<ProviderUsageFixture> {
let requests = 0;
const waiters: Array<{ count: number; resolve: () => void }> = [];
function notifyWaiters() {
for (const waiter of waiters.splice(0)) {
if (requests >= waiter.count) {
waiter.resolve();
} else {
waiters.push(waiter);
}
}
}
function payloadForRequest(): ProviderUsageFixturePayload {
const index = Math.min(requests - 1, payloads.length - 1);
const payload = payloads[index];
if (!payload) {
throw new Error("Provider usage fixture requires at least one payload.");
}
return payload;
}
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
if (sessionMessage?.type === "provider.usage.list.request") {
requests += 1;
const requestId = sessionMessage.requestId;
if (typeof requestId !== "string") {
throw new Error("provider.usage.list.request missing requestId");
}
const payload = payloadForRequest();
notifyWaiters();
ws.send(
JSON.stringify({
type: "session",
message: {
type: "provider.usage.list.response",
payload: {
requestId,
fetchedAt: payload.fetchedAt,
providers: payload.providers,
},
},
}),
);
return;
}
server.send(message);
});
server.onMessage((message) => {
const serverInfo = typeof message === "string" ? withProviderUsageFeature(message) : null;
ws.send(serverInfo ?? message);
});
});
return {
requestCount() {
return requests;
},
waitForRequestCount(count: number) {
if (requests >= count) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
waiters.push({ count, resolve });
});
},
};
}
+37 -2
View File
@@ -20,8 +20,12 @@ interface SavedSettingsHostInput {
const SECTION_LABELS = {
general: "General",
chat: "Chat",
appearance: "Appearance",
sidebar: "Sidebar",
editor: "Editor",
terminal: "Terminal",
browser: "Browser",
shortcuts: "Shortcuts",
integrations: "Integrations",
permissions: "Permissions",
@@ -70,7 +74,7 @@ export async function openSettingsHostSection(
}
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(title);
await expect(page.getByTestId("page-title")).toHaveText(title);
}
export async function openAddHostFlow(page: Page): Promise<void> {
@@ -91,12 +95,15 @@ export async function selectHostConnectionType(
export async function addDirectHostFromSettings(
page: Page,
input: { host: string; port: number },
input: { host: string; port: number; password?: string },
): Promise<void> {
await openAddHostFlow(page);
await selectHostConnectionType(page, "direct");
await page.getByTestId("direct-host-input").fill(input.host);
await page.getByTestId("direct-port-input").fill(String(input.port));
if (input.password) {
await page.getByTestId("direct-password-input").fill(input.password);
}
await page.getByTestId("direct-host-submit").click();
await expect(page.getByTestId("add-host-modal")).toHaveCount(0, { timeout: 30_000 });
}
@@ -418,3 +425,31 @@ export async function expectLocalHostEntryFirst(page: Page, _serverId: string):
await expect(picker).toBeVisible();
await expect(picker.getByText(TEST_HOST_LABEL, { exact: true })).toBeVisible();
}
export async function expectHostRejectedWithReAddGuidance(
page: Page,
reason: string,
): Promise<void> {
const error = page.getByTestId("host-connection-error");
await expect(error).toContainText(reason);
await expect(error).toContainText(
"Remove this host and add it again with the password this daemon asks for.",
);
}
export async function expectNoHostPasswordControls(page: Page): Promise<void> {
await expect(page.getByText("Password", { exact: true })).toHaveCount(0);
await expect(page.locator('input[type="password"]')).toHaveCount(0);
await expect(page.getByRole("button", { name: /password/i })).toHaveCount(0);
}
export async function removeHostFromHostPage(page: Page, serverId: string): Promise<void> {
await page.getByTestId("host-page-remove-host-button").click();
await page.getByTestId("remove-host-confirm").click();
await expect(page).not.toHaveURL(new RegExp(`/settings/hosts/${serverId}`));
}
export async function expectHostOnlineWithoutError(page: Page): Promise<void> {
await expect(page.getByTestId("host-page-identity")).toContainText("Online");
await expect(page.getByTestId("host-connection-error")).toHaveCount(0);
}
@@ -57,7 +57,7 @@ export async function seedSidebarNavPreferences(
export async function openSidebarNavSettings(page: Page): Promise<void> {
await openSettings(page);
await openSettingsSection(page, "appearance");
await openSettingsSection(page, "sidebar");
await expect(page.getByTestId("sidebar-nav-section")).toBeVisible({ timeout: 30_000 });
}
@@ -0,0 +1,178 @@
import type { Page } from "@playwright/test";
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { daemonWsRoutePattern } from "./daemon-port";
export interface UsageReportsFixture {
listRequests(): Array<{ forceRefresh: boolean; reportIds?: string[] }>;
agentRequests(): Array<{ agentId: string }>;
waitForListRequests(count: number): Promise<void>;
waitForAgentRequests(count: number): Promise<void>;
}
interface UsageReportsFixtureOptions {
/**
* Successive `usage.list_reports` responses; the last one repeats. `{ error }` fails that
* request; a function builds the response when the request arrives (e.g. a fresh `fetchedAt`).
*/
lists?: Array<UsageListResponse | (() => UsageListResponse)>;
/** Successive agent report IDs; the last one repeats. */
agentReportIds?: Array<string | null>;
/** Advertise `features.usageSources`. False simulates a host from before usage sources. */
usageSources?: boolean;
}
type UsageListResponse = UsageReportEntry[] | { error: string };
type WebSocketMessage = string | Buffer;
function parseJson(message: WebSocketMessage): unknown {
const raw = typeof message === "string" ? message : message.toString("utf8");
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
const envelope = parseJson(message) as { type?: unknown; message?: unknown } | null;
if (!envelope || envelope.type !== "session" || typeof envelope.message !== "object") {
return null;
}
return envelope.message as Record<string, unknown>;
}
function withUsageSourcesFeature(message: WebSocketMessage, enabled: boolean): string | null {
const envelope = parseJson(message) as {
type?: unknown;
message?: { type?: unknown; payload?: Record<string, unknown> };
} | null;
const payload = envelope?.message?.payload;
if (
envelope?.type !== "session" ||
envelope.message?.type !== "status" ||
payload?.status !== "server_info"
) {
return null;
}
const features =
typeof payload.features === "object" && payload.features !== null ? payload.features : {};
return JSON.stringify({
...envelope,
message: {
...envelope.message,
payload: { ...payload, features: { ...features, usageSources: enabled } },
},
});
}
function pick<T>(values: T[], index: number): T {
const value = values[Math.min(index, values.length - 1)];
if (value === undefined) throw new Error("Usage fixture requires at least one response.");
return value;
}
function createCounter() {
let count = 0;
const waiters: Array<{ count: number; resolve: () => void }> = [];
return {
increment() {
count += 1;
for (const waiter of waiters.splice(0)) {
if (count >= waiter.count) waiter.resolve();
else waiters.push(waiter);
}
},
waitFor(target: number): Promise<void> {
if (count >= target) return Promise.resolve();
return new Promise((resolve) => waiters.push({ count: target, resolve }));
},
};
}
export async function installUsageReportsFixture(
page: Page,
options: UsageReportsFixtureOptions,
): Promise<UsageReportsFixture> {
const listRequests: Array<{ forceRefresh: boolean; reportIds?: string[] }> = [];
const agentRequests: Array<{ agentId: string }> = [];
const listCounter = createCounter();
const agentCounter = createCounter();
const usageSources = options.usageSources ?? true;
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const request = getSessionMessage(message);
const requestId = request?.requestId;
if (request?.type === "usage.list_reports.request" && typeof requestId === "string") {
listRequests.push({
forceRefresh: request.forceRefresh === true,
reportIds: Array.isArray(request.reportIds) ? (request.reportIds as string[]) : undefined,
});
const scripted = pick(options.lists ?? [[]], listRequests.length - 1);
const response = typeof scripted === "function" ? scripted() : scripted;
if ("error" in response) {
ws.send(
JSON.stringify({
type: "session",
message: {
type: "rpc_error",
payload: {
requestId,
requestType: "usage.list_reports.request",
error: response.error,
code: "transport",
},
},
}),
);
listCounter.increment();
return;
}
const ids = Array.isArray(request.reportIds) ? request.reportIds : null;
const reports = ids ? response.filter((entry) => ids.includes(entry.id)) : response;
ws.send(
JSON.stringify({
type: "session",
message: { type: "usage.list_reports.response", payload: { requestId, reports } },
}),
);
listCounter.increment();
return;
}
if (request?.type === "agent.resolve_usage_report.request" && typeof requestId === "string") {
agentRequests.push({
agentId: String(request.agentId),
});
const reportId = pick(options.agentReportIds ?? [null], agentRequests.length - 1);
ws.send(
JSON.stringify({
type: "session",
message: {
type: "agent.resolve_usage_report.response",
payload: { requestId, reportId },
},
}),
);
agentCounter.increment();
return;
}
server.send(message);
});
server.onMessage((message) => {
const serverInfo =
typeof message === "string" ? withUsageSourcesFeature(message, usageSources) : null;
ws.send(serverInfo ?? message);
});
});
return {
listRequests: () => [...listRequests],
agentRequests: () => [...agentRequests],
waitForListRequests: (count) => listCounter.waitFor(count),
waitForAgentRequests: (count) => agentCounter.waitFor(count),
};
}
@@ -0,0 +1,176 @@
import { mkdtemp, rm } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { getAvailableHostDaemonPort } from "./isolated-host-daemon";
import { killProcessTree, spawnTsx } from "./spawn-node";
const VERSIONED_DAEMON_ENTRYPOINT = path.resolve(
__dirname,
"../../../../server/src/server/test-utils/versioned-daemon.ts",
);
const READY_TIMEOUT_MS = 30_000;
// A host daemon that can be replaced by a daemon reporting another version under the same
// endpoint and serverId, the way an upgraded host comes back.
export interface RestartableHostDaemon {
serverId: string;
port: number;
stop(): Promise<void>;
restartWithVersion(version: string): Promise<void>;
dispose(): Promise<void>;
}
export async function startRestartableHostDaemon(version: string): Promise<RestartableHostDaemon> {
const paseoHomeRoot = await mkdtemp(path.join(tmpdir(), "paseo-e2e-restartable-host-"));
const port = await getAvailableHostDaemonPort();
let current: VersionedHostDaemon | null = null;
async function dispose(): Promise<void> {
await current?.stop();
current = null;
await rm(paseoHomeRoot, { recursive: true, force: true });
}
try {
current = await startVersionedHostDaemon({ version, port, paseoHomeRoot });
} catch (error) {
await dispose();
throw error;
}
const { serverId } = current;
return {
serverId,
port,
async stop() {
await current?.stop();
current = null;
await waitForPortReleased(port);
},
async restartWithVersion(nextVersion) {
await current?.stop();
current = null;
await waitForPortReleased(port);
current = await startVersionedHostDaemon({ version: nextVersion, port, paseoHomeRoot });
if (current.serverId !== serverId) {
throw new Error(
`Restarted host daemon reported serverId ${current.serverId}, expected ${serverId}`,
);
}
},
dispose,
};
}
interface VersionedHostDaemon {
serverId: string;
port: number;
version: string;
stop(): Promise<void>;
}
interface VersionedHostDaemonOptions {
version: string;
port: number;
paseoHomeRoot: string;
}
// Starts the versioned test daemon on an explicit port with a caller-owned PASEO_HOME. The caller
// removes the home root.
async function startVersionedHostDaemon(
options: VersionedHostDaemonOptions,
): Promise<VersionedHostDaemon> {
const child = spawnTsx(
VERSIONED_DAEMON_ENTRYPOINT,
[options.version, String(options.port), options.paseoHomeRoot],
{ stdio: ["ignore", "pipe", "pipe", "ipc"] },
);
const ready = Promise.withResolvers<{ port: number; serverId: string }>();
let output = "";
child.stdout?.on("data", (chunk: Buffer) => {
output += chunk.toString("utf8");
});
child.stderr?.on("data", (chunk: Buffer) => {
output += chunk.toString("utf8");
});
child.once("error", ready.reject);
child.once("exit", () => {
ready.reject(
new Error(`Versioned daemon ${options.version} exited before reporting ready:\n${output}`),
);
});
child.once("message", (message) => {
if (!isReadyMessage(message)) {
ready.reject(new Error("Invalid versioned daemon startup message"));
return;
}
ready.resolve(message);
});
const timeout = setTimeout(
() =>
ready.reject(
new Error(
`Versioned daemon ${options.version} did not report ready within ${READY_TIMEOUT_MS}ms:\n${output}`,
),
),
READY_TIMEOUT_MS,
);
let stopped = false;
async function stop(): Promise<void> {
if (stopped) return;
stopped = true;
await killProcessTree(child);
}
try {
const readyMessage = await ready.promise;
if (readyMessage.port !== options.port) {
throw new Error(
`Versioned daemon ${options.version} bound port ${readyMessage.port}, expected ${options.port}`,
);
}
return {
serverId: readyMessage.serverId,
port: readyMessage.port,
version: options.version,
stop,
};
} catch (error) {
await stop();
throw error;
} finally {
clearTimeout(timeout);
}
}
// A replacement daemon can only bind the port the stopped daemon just released.
async function waitForPortReleased(port: number, timeoutMs = 15_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await isPortAvailable(port)) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`Port ${port} was not released within ${timeoutMs}ms`);
}
function isReadyMessage(message: unknown): message is { port: number; serverId: string } {
return (
typeof message === "object" &&
message !== null &&
"port" in message &&
typeof message.port === "number" &&
"serverId" in message &&
typeof message.serverId === "string"
);
}
function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
server.once("error", () => resolve(false));
server.listen(port, "127.0.0.1", () => {
server.close(() => resolve(true));
});
});
}
+2 -1
View File
@@ -19,7 +19,8 @@
"APP_VARIANT": "production"
},
"android": {
"resourceClass": "large"
"resourceClass": "large",
"gradleCommand": ":app:bundleRelease -x lint -x lintVitalAnalyzeRelease -x lintVitalRelease -x generateReleaseLintModel -x generateReleaseLintVitalModel"
}
},
"production-apk": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.9.2",
"version": "0.10.0",
"private": true,
"main": "index.ts",
"scripts": {
@@ -12,7 +12,6 @@ describe("chat outline prompt index epoch", () => {
it("accepts only the authoritative timeline epoch", () => {
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-2")).toBe(true);
expect(shouldAcceptPromptIndexEpoch("epoch-2", "epoch-1")).toBe(false);
expect(shouldAcceptPromptIndexEpoch(null, "epoch-1")).toBe(true);
});
});
@@ -2,11 +2,8 @@ import type { AgentTimelinePromptIndexPayload } from "@getpaseo/client/internal/
export type ChatOutlinePrompt = AgentTimelinePromptIndexPayload["prompts"][number];
export function shouldAcceptPromptIndexEpoch(
timelineEpoch: string | null,
indexEpoch: string,
): boolean {
return timelineEpoch === null || timelineEpoch === indexEpoch;
export function shouldAcceptPromptIndexEpoch(timelineEpoch: string, indexEpoch: string): boolean {
return timelineEpoch === indexEpoch;
}
/**
@@ -37,6 +37,31 @@ describe("useChatOutline", () => {
runtime.subscribeAgentTimeline.mockClear();
});
it("waits for a served timeline before requesting its prompt index", async () => {
runtime.listAgentTimelinePrompts.mockResolvedValue({ epoch: "epoch-1", prompts: [] });
const viewportRef = createRef<StreamViewportHandle>();
const { rerender } = renderHook(
({ agentId, timelineEpoch }) =>
useChatOutline({
agentId,
serverId: "server-1",
timelineEpoch,
tail: [],
head: [],
enabled: true,
viewportRef,
onJumpError: vi.fn(),
}),
{ initialProps: { agentId: "draft_msg_1", timelineEpoch: null as string | null } },
);
expect(runtime.listAgentTimelinePrompts).not.toHaveBeenCalled();
rerender({ agentId: "agent-1", timelineEpoch: "epoch-1" });
await waitFor(() => expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledTimes(1));
expect(runtime.listAgentTimelinePrompts).toHaveBeenCalledWith("agent-1");
});
it("drops a late prompt index after the authoritative timeline epoch changes", async () => {
const first = deferred<{ epoch: string; prompts: [] }>();
const second = deferred<{
@@ -77,8 +77,10 @@ export function useChatOutline({
useEffect(() => setIndex(null), [agentId, enabled, serverId, timelineEpoch]);
// Only a timeline the daemon has served can be indexed. A draft's optimistic stream has no
// epoch, and its id names no agent the daemon knows.
useEffect(() => {
if (!isWeb || !enabled) {
if (!isWeb || !enabled || timelineEpoch === null) {
setIndex(null);
return;
}
@@ -210,6 +210,17 @@ describe("stream presentation through installed plugins", () => {
expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]);
});
// Codex streams a line break and the next line's indent as their own chunks.
it("keeps a line break when a chunk ends in the next line's indent", () => {
const harness = streamHarness();
harness.send(assistant("```mermaid\nflowchart TD\n A[Start] --> B[Review]\n"));
harness.send(assistant(" "));
const result = harness.send(assistant(" B --> C[Finish]\n```"));
expect(result.head).toMatchObject([
{ text: "```mermaid\nflowchart TD\n A[Start] --> B[Review]\n B --> C[Finish]\n```" },
]);
});
// One rendering path: a fetched message is the same block group as a streamed one,
// which is what lets find, scroll-to-message and history reveal address it by id.
// A link reference definition stays with the paragraph that uses it, so the split
@@ -112,8 +112,10 @@ export function createStreamPresentation() {
const index = prefix.length + offset;
let blockText = text;
if (offset === textBlocks.length - 1) {
const trailingNewlines = /\n+$/.exec(item.text)?.[0] ?? "";
blockText += trailingNewlines;
// The split drops trailing blank lines, including a line break followed only
// by the next line's indent. Keep them so the next append continues that line.
const trailingBlankLines = /\n\s*$/.exec(item.text)?.[0] ?? "";
blockText += trailingBlankLines;
}
const existing = previous?.[index];
const id = `${item.id}:block:${index}`;
+16 -26
View File
@@ -105,7 +105,6 @@ import {
getHostRuntimeStore,
hasConfiguredLocalDaemonOverride,
useHostRegistryLoaded,
useHostMutations,
useHostRuntimeClient,
useHostRuntimeIsConnected,
useHosts,
@@ -666,13 +665,11 @@ function MobileGestureWrapper({
}
function ProvidersWrapper({ children }: { children: ReactNode }) {
const { upsertConnectionFromOfferUrl } = useHostMutations();
return (
<AppearanceProvider>
<VoiceProvider>
<DesktopWindowControlsSync />
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
<OfferLinkListener />
<HostSessionManager />
<FaviconStatusSync />
{children}
@@ -699,45 +696,36 @@ function DesktopWindowControlsSync() {
return null;
}
function OfferLinkListener({
upsertDaemonFromOfferUrl,
}: {
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<unknown>;
}) {
function OfferLinkListener() {
const router = useRouter();
useEffect(() => {
let cancelled = false;
const handleUrl = (url: string | null) => {
const handleUrl = async (url: string | null) => {
if (!url) return;
if (!url.includes("#offer=")) return;
void upsertDaemonFromOfferUrl(url)
.then((profile) => {
if (cancelled) return;
const serverId = (profile as { serverId?: unknown } | null)?.serverId;
if (typeof serverId !== "string" || !serverId) return;
router.replace(buildOpenProjectRoute());
return;
})
.catch((error) => {
if (cancelled) return;
console.warn("[Linking] Failed to import pairing offer", error);
});
if (!url.includes("#offer=") && !url.includes("#connect=") && !url.startsWith("relay://"))
return;
try {
const result = await getHostRuntimeStore().importConnectionLink(url, "openProject");
if (!cancelled && result.status === "connected") router.replace(buildOpenProjectRoute());
} catch (error) {
console.warn("[OfferLinkListener] Pairing link failed", error);
}
};
void Linking.getInitialURL()
.then(handleUrl)
.then((url) => handleUrl(url))
.catch(() => undefined);
const subscription = Linking.addEventListener("url", (event) => {
handleUrl(event.url);
void handleUrl(event.url);
});
return () => {
cancelled = true;
subscription.remove();
};
}, [router, upsertDaemonFromOfferUrl]);
}, [router]);
return null;
}
@@ -872,6 +860,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
pathname === "/new" ||
pathname === "/sessions" ||
pathname === "/schedules" ||
pathname === "/usage" ||
routeHasKnownHost);
return <AppContainer chromeEnabled={shouldShowAppChrome}>{children}</AppContainer>;
@@ -904,6 +893,7 @@ function RootStack() {
<Stack.Screen name="open-project" />
<Stack.Screen name="sessions" />
<Stack.Screen name="schedules" />
<Stack.Screen name="usage" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="h/[serverId]" />
+36 -43
View File
@@ -1,18 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Pressable, Text, View } from "react-native";
import { Pressable, Text, View } from "react-native";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
import { useHostMutations } from "@/runtime/host-runtime";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { isWeb } from "@/constants/platform";
import { BackHeader } from "@/components/headers/back-header";
import { PairLinkModal } from "@/components/pair-link-modal";
const styles = StyleSheet.create((theme) => ({
container: {
@@ -114,7 +112,8 @@ function extractOfferUrlFromScan(result: BarcodeScanningResult): string | null {
const raw = typeof result.data === "string" ? result.data.trim() : "";
if (!raw) return null;
if (raw.includes("#offer=")) return raw;
if (raw.includes("#offer=") || raw.includes("#connect=") || raw.startsWith("relay://"))
return raw;
return null;
}
@@ -128,11 +127,11 @@ export default function PairScanScreen() {
source?: string;
}>();
const source = typeof params.source === "string" ? params.source : "settings";
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const [permission, requestPermission] = useCameraPermissions();
const [isPairing, setIsPairing] = useState(false);
const lastScannedRef = useRef<string | null>(null);
const [scanError, setScanError] = useState<string | null>(null);
const [passwordOfferUrl, setPasswordOfferUrl] = useState<string | null>(null);
const navigateToPairedHost = useCallback(
(serverId: string) => {
@@ -160,48 +159,34 @@ export default function PairScanScreen() {
}, [permission, requestPermission]);
const handleScan = useCallback(
async (result: BarcodeScanningResult) => {
(result: BarcodeScanningResult) => {
if (isPairing) return;
const offerUrl = extractOfferUrlFromScan(result);
if (!offerUrl) return;
if (lastScannedRef.current === offerUrl) return;
lastScannedRef.current = offerUrl;
try {
setIsPairing(true);
const idx = offerUrl.indexOf("#offer=");
const encoded = offerUrl.slice(idx + "#offer=".length).trim();
const offerPayload = decodeOfferFragmentPayload(encoded);
const offer = ConnectionOfferSchema.parse(offerPayload);
const { client, hostname } = await connectToDaemon(
{
id: "probe",
type: "relay",
relayEndpoint: normalizeHostPort(offer.relay.endpoint),
useTls: offer.relay.useTls,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
},
{ serverId: offer.serverId },
);
await client.close().catch(() => undefined);
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
navigateToPairedHost(profile.serverId);
} catch (error) {
lastScannedRef.current = null;
const message = error instanceof Error ? error.message : t("pairing.scan.unableToPair");
Alert.alert(t("pairing.scan.errorTitle"), message);
} finally {
setIsPairing(false);
}
const store = getHostRuntimeStore();
if (passwordOfferUrl) return;
setIsPairing(true);
setScanError(null);
void store
.importConnectionLink(offerUrl, source === "onboarding" ? "hostRoot" : "hostSettings")
.then((outcome) => {
if (outcome.status === "connected") navigateToPairedHost(outcome.serverId);
else setPasswordOfferUrl(offerUrl);
return outcome;
})
.catch((error) => setScanError(error instanceof Error ? error.message : String(error)))
.finally(() => setIsPairing(false));
},
[isPairing, navigateToPairedHost, t, upsertDaemonFromOfferUrl],
[isPairing, navigateToPairedHost, passwordOfferUrl, source],
);
const handleRouterBack = useCallback(() => router.back(), [router]);
const closePasswordModal = useCallback(() => setPasswordOfferUrl(null), []);
const savePasswordPairing = useCallback(
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
[navigateToPairedHost],
);
const handleRequestPermission = useCallback(() => {
void requestPermission();
}, [requestPermission]);
@@ -263,10 +248,18 @@ export default function PairScanScreen() {
<View style={[styles.corner, styles.cornerBR]} />
</View>
{isPairing ? <Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text> : null}
{scanError ? <Text style={helperTextStyle}>{scanError}</Text> : null}
</View>
</View>
)}
</View>
<PairLinkModal
visible={passwordOfferUrl !== null}
initialUrl={passwordOfferUrl ?? undefined}
initialPasswordRequired
onClose={closePasswordModal}
onSaved={savePasswordPairing}
/>
</View>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { UsageScreen } from "@/usage";
export default function UsageRoute() {
return (
<HostRouteBootstrapBoundary>
<UsageScreen />
</HostRouteBootstrapBoundary>
);
}
+2 -33
View File
@@ -10,6 +10,7 @@ import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { markdownCopyDataSet } from "@/assistant-selection-copy/markup";
import { useAssistantFileLinkResolverContext } from "./provider";
import type { AssistantFileLinkSource } from "./resolver";
import { formatFileLinkTooltipPath } from "./tooltip-path";
import { useFileLink } from "./use-file-link";
interface AssistantMarkdownLinkProps {
@@ -34,7 +35,7 @@ export function AssistantMarkdownLink({
const { configRef } = useAssistantFileLinkResolverContext();
const workspaceRoot = configRef.current.workspaceRoot;
const tooltipPath = useMemo(
() => (target ? formatInlinePathTargetForTooltip(target, workspaceRoot) : null),
() => (target ? formatFileLinkTooltipPath({ target, workspaceRoot }) : null),
[target, workspaceRoot],
);
const linkPress = useMemo<AssistantLinkPress>(
@@ -127,38 +128,6 @@ export function AssistantMarkdownCodeLink({
);
}
function formatInlinePathTargetForTooltip(
target: { path: string; lineStart?: number; lineEnd?: number },
workspaceRoot: string | undefined,
): string {
let result = relativizePathToWorkspace(target.path, workspaceRoot);
if (target.lineStart) {
result += `:${target.lineStart}`;
if (target.lineEnd && target.lineEnd !== target.lineStart) {
result += `-${target.lineEnd}`;
}
}
return result;
}
function relativizePathToWorkspace(filePath: string, workspaceRoot: string | undefined): string {
if (!workspaceRoot) {
return filePath;
}
const root = workspaceRoot.replace(/\/+$/, "");
if (!root) {
return filePath;
}
if (filePath === root) {
return ".";
}
const prefix = `${root}/`;
if (filePath.startsWith(prefix)) {
return filePath.slice(prefix.length);
}
return filePath;
}
interface AssistantInlineCodePathLinkProps {
content: string;
inheritedStyles: TextStyle;
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { formatFileLinkTooltipPath } from "./tooltip-path";
describe("formatFileLinkTooltipPath", () => {
it("shows a Windows file path relative to its workspace regardless of separators", () => {
expect(
formatFileLinkTooltipPath({
target: {
path: "C:/Users/me/repo/src/app.ts",
lineStart: 12,
lineEnd: 20,
},
workspaceRoot: "C:\\Users\\me\\repo",
}),
).toBe("src/app.ts:12-20");
expect(
formatFileLinkTooltipPath({
target: { path: "C:/src/app.ts", lineStart: 12 },
workspaceRoot: "C:\\",
}),
).toBe("src/app.ts:12");
});
it("shows the workspace root as a dot", () => {
expect(
formatFileLinkTooltipPath({
target: { path: "/Users/me/repo" },
workspaceRoot: "/Users/me/repo",
}),
).toBe(".");
expect(
formatFileLinkTooltipPath({
target: { path: "C:/Users/Me/Repo/", lineStart: 4 },
workspaceRoot: "c:\\users\\me\\repo",
}),
).toBe(".:4");
});
it("keeps an absolute path outside the workspace", () => {
expect(
formatFileLinkTooltipPath({
target: { path: "/Users/me/notes.md" },
workspaceRoot: "/Users/me/repo",
}),
).toBe("/Users/me/notes.md");
expect(
formatFileLinkTooltipPath({
target: { path: "C:/Users/me/notes.md", lineStart: 8 },
workspaceRoot: "C:\\Users\\me\\repo",
}),
).toBe("C:/Users/me/notes.md:8");
});
it("keeps the target path when the workspace root is unavailable", () => {
expect(formatFileLinkTooltipPath({ target: { path: "src/app.ts", lineStart: 12 } })).toBe(
"src/app.ts:12",
);
});
});
@@ -0,0 +1,30 @@
import {
isWorkspaceRootPath,
resolveWorkspaceFilePaths,
type WorkspaceFileLocation,
} from "@/workspace/file-open";
interface FormatFileLinkTooltipPathInput {
target: WorkspaceFileLocation;
workspaceRoot?: string;
}
export function formatFileLinkTooltipPath({
target,
workspaceRoot,
}: FormatFileLinkTooltipPathInput): string {
const resolvedPaths = workspaceRoot
? resolveWorkspaceFilePaths({ path: target.path, workspaceRoot })
: null;
const isWorkspaceRoot = workspaceRoot
? isWorkspaceRootPath({ path: target.path, workspaceRoot })
: false;
let result = isWorkspaceRoot ? "." : (resolvedPaths?.relativePath ?? target.path);
if (target.lineStart) {
result += `:${target.lineStart}`;
if (target.lineEnd && target.lineEnd !== target.lineStart) {
result += `-${target.lineEnd}`;
}
}
return result;
}
@@ -112,6 +112,7 @@ function ChangelogBody({ state, shownReleases, onShowMore, onRetry }: ChangelogB
return (
<View style={styles.centered}>
<Alert
size="md"
variant="error"
title={t("changelog.error.title")}
description={t("changelog.error.description")}
+62 -6
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useReducer, useState } from "react";
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -11,9 +11,13 @@ import {
serializeConnectionUri,
serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
import {
DaemonConnectionTestError,
getConnectionAuthFailureReason,
} from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { PairingTargetTracker } from "./pair-link-credentials";
const FLEX_ONE_STYLE = { flex: 1 } as const;
@@ -250,8 +254,8 @@ function buildConnectionFailureCopy(input: {
const rawLower = raw?.toLowerCase() ?? "";
let detail: string | null = null;
if (raw === "Incorrect password" || raw === "Password required") {
detail = raw;
if (getConnectionAuthFailureReason(error)) {
detail = error instanceof Error ? error.message : raw;
} else if (rawLower.includes("timed out")) {
detail = labels.timedOut;
} else if (
@@ -293,7 +297,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { probeAndUpsertDirectConnection } = useHostMutations();
const { probeAndUpsertDirectConnection, probeAndUpsertConnectionFromOfferUrl } =
useHostMutations();
const isMobile = useIsCompactFormFactor();
const [isSaving, setIsSaving] = useState(false);
@@ -306,6 +311,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const [advancedUri, setAdvancedUri] = useState("");
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
const advancedTarget = useRef(new PairingTargetTracker("", true));
const clearInput = useCallback(() => {
setHost("");
@@ -315,6 +321,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsPasswordVisible(false);
setIsAdvancedOpen(false);
setAdvancedUri("");
advancedTarget.current = new PairingTargetTracker("", true);
bumpInputResetKey();
}, []);
@@ -365,9 +372,45 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
(onCancel ?? onClose)();
}, [isSaving, clearInput, onCancel, onClose]);
const handleSaveRelay = useCallback(
async (relayUri: string) => {
try {
setIsSaving(true);
setErrorMessage("");
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
relayUri,
password || undefined,
);
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
onSaved?.({ profile, serverId, hostname, isNewHost });
handleClose();
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : directConnectionLabels.invalidConnection,
);
} finally {
setIsSaving(false);
}
},
[
daemons,
directConnectionLabels.invalidConnection,
handleClose,
onSaved,
password,
probeAndUpsertConnectionFromOfferUrl,
],
);
const handleSave = useCallback(async () => {
if (isSaving) return;
const relayUri = isAdvancedOpen ? advancedUri.trim() : "";
if (relayUri.startsWith("relay://") || relayUri.includes("#connect=")) {
await handleSaveRelay(relayUri);
return;
}
let connection: PreparedDirectConnection;
try {
connection = prepareDirectConnection(
@@ -422,10 +465,12 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsSaving(false);
}
}, [
advancedUri,
daemons,
directConnectionLabels,
handleClose,
host,
isAdvancedOpen,
isMobile,
isSaving,
onSaved,
@@ -433,6 +478,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
port,
probeAndUpsertDirectConnection,
t,
handleSaveRelay,
useTls,
]);
@@ -444,6 +490,15 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
void handleSave();
}, [handleSave]);
const handleChangeAdvancedUri = useCallback((next: string) => {
if (advancedTarget.current.changeUrl(next)) {
setPassword("");
bumpInputResetKey();
setErrorMessage("");
}
setAdvancedUri(next);
}, []);
const handleToggleUseTls = useCallback(() => {
if (isSaving) return;
setUseTls((current) => !current);
@@ -455,6 +510,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const handleToggleAdvanced = useCallback(() => {
if (!isAdvancedOpen) {
advancedTarget.current = new PairingTargetTracker("", true);
try {
setAdvancedUri(
buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels),
@@ -612,7 +668,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
initialValue={advancedUri}
resetKey={`direct-host-uri-${inputResetKey}`}
onChangeText={setAdvancedUri}
onChangeText={handleChangeAdvancedUri}
placeholder="tcp://localhost:6767?ssl=true"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
+12 -6
View File
@@ -14,7 +14,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { formatTimeAgo } from "@/utils/time";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import { Archive, ChevronRight } from "lucide-react-native";
@@ -181,7 +181,6 @@ function SessionRow({
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const timeAgo = formatTimeAgo(agent.lastActivityAt);
const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey;
const projectName = agent.projectPlacement?.projectName ?? "";
@@ -283,7 +282,7 @@ function SessionRow({
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
/>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText}>{timeAgo}</Text>
<AgentActivityTime date={agent.lastActivityAt} isMobile />
{showHostColumn && agent.serverLabel ? (
<>
<Text style={styles.sessionMetaSeparator}>·</Text>
@@ -316,9 +315,7 @@ function SessionRow({
numberOfLines={1}
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
/>
<Text style={styles.columnMetaFixed} numberOfLines={1}>
{timeAgo}
</Text>
<AgentActivityTime date={agent.lastActivityAt} isMobile={false} />
</View>
) : null}
<SessionRowTrailingAttention
@@ -330,6 +327,15 @@ function SessionRow({
);
}
function AgentActivityTime({ date, isMobile }: { date: Date; isMobile: boolean }) {
const label = useTimeAgo(date);
return (
<Text style={isMobile ? styles.sessionMetaText : styles.columnMetaFixed} numberOfLines={1}>
{label}
</Text>
);
}
export function AgentList({
agents,
isRefreshing = false,
@@ -1,11 +1,8 @@
import { useCallback, useState } from "react";
import { Pressable, Text, View } from "react-native";
import Svg, { Circle } from "react-native-svg";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ProviderUsageTooltipSection } from "@/provider-usage/tooltip-section";
import { useProviderUsage } from "@/provider-usage/use-provider-usage";
import { formatTokenCount } from "./context-window-meter.utils";
interface ContextWindowMeterProps {
@@ -13,9 +10,6 @@ interface ContextWindowMeterProps {
usedTokens: number | null;
totalCostUsd?: number | null;
showPercentage?: boolean;
serverId?: string;
/** The Paseo provider key, e.g. "claude", "gemini", "codex" */
provider?: string | null;
/** Reserve the meter footprint and show a loading ring while usage is pending. */
pending?: boolean;
/** Optional glyph envelope for icon-toolbar alignment. */
@@ -101,30 +95,13 @@ export function ContextWindowMeter({
usedTokens,
totalCostUsd,
showPercentage = false,
serverId,
provider,
pending = false,
glyphSize,
}: ContextWindowMeterProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
const { view: providerUsageView, refresh: refreshProviderUsage } = useProviderUsage(
serverId ?? null,
{ enabled: isTooltipOpen },
);
const percentage =
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
const handleTooltipOpenChange = useCallback(
(nextOpen: boolean) => {
setIsTooltipOpen(nextOpen);
if (nextOpen) {
void refreshProviderUsage().catch(() => {});
}
},
[refreshProviderUsage],
);
const geometry = getMeterGeometry(showPercentage, glyphSize);
// No usage yet: reserve the footprint with a track-only ring while a session is
@@ -140,7 +117,6 @@ export function ContextWindowMeter({
width={geometry.svgSize}
height={geometry.svgSize}
viewBox={`0 0 ${geometry.svgSize} ${geometry.svgSize}`}
style={styles.svg}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
@@ -167,13 +143,7 @@ export function ContextWindowMeter({
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
return (
<Tooltip
open={isTooltipOpen}
onOpenChange={handleTooltipOpenChange}
delayDuration={0}
enabledOnDesktop
enabledOnMobile
>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
style={containerStyle}
@@ -187,7 +157,6 @@ export function ContextWindowMeter({
width={svgSize}
height={svgSize}
viewBox={`0 0 ${svgSize} ${svgSize}`}
style={styles.svg}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
@@ -209,6 +178,8 @@ export function ContextWindowMeter({
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
// SVG strokes start at three o'clock; the ring reads clockwise from twelve.
transform={`rotate(-90 ${center} ${center})`}
/>
</Svg>
{showPercentage ? (
@@ -233,7 +204,6 @@ export function ContextWindowMeter({
{t("contextWindow.sessionCost", { cost: formattedSessionCost })}
</Text>
) : null}
<ProviderUsageTooltipSection view={providerUsageView} activeProviderId={provider} />
</View>
</TooltipContent>
</Tooltip>
@@ -256,9 +226,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
borderRadius: theme.borderRadius.full,
},
svg: {
transform: [{ rotate: "-90deg" }],
},
percentageLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
@@ -14,7 +14,7 @@ import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { getProviderIcon } from "@/components/provider-icons";
import { formatTimeAgo } from "@/utils/time";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useHostProjects } from "@/projects/host-projects";
@@ -309,7 +309,6 @@ function ImportSessionSheetRow({
const { t } = useTranslation();
const title = getSessionTitle(entry);
const promptPreview = getPromptPreview(entry);
const lastActivity = formatTimeAgo(new Date(entry.lastActivityAt));
const ProviderIcon = getProviderIcon(entry.providerId, serverId);
const accessibilityState = useMemo(
() => (disabled ? DISABLED_ACCESSIBILITY_STATE : undefined),
@@ -344,9 +343,11 @@ function ImportSessionSheetRow({
<Text style={styles.rowTitle} numberOfLines={1}>
{title}
</Text>
<Text style={styles.rowMeta}>
{importing ? t("importSession.row.importing") : lastActivity}
</Text>
{importing ? (
<Text style={styles.rowMeta}>{t("importSession.row.importing")}</Text>
) : (
<ImportSessionActivityTime date={entry.lastActivityAt} />
)}
</View>
<Text style={styles.rowPreview} numberOfLines={2}>
{promptPreview}
@@ -365,6 +366,11 @@ function ImportSessionSheetRow({
);
}
function ImportSessionActivityTime({ date }: { date: string }) {
const label = useTimeAgo(new Date(date));
return <Text style={styles.rowMeta}>{label}</Text>;
}
function SessionRows({
serverId,
entries,
+26 -2
View File
@@ -1,5 +1,5 @@
import { router } from "expo-router";
import { FolderPlus, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
import { FolderPlus, Gauge, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
@@ -47,7 +47,11 @@ import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import {
buildSettingsAddHostRoute,
buildSettingsRoute,
buildUsageRoute,
} from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
@@ -426,6 +430,25 @@ function SidebarHostPicker({
);
}
function SidebarUsageButton({ theme }: { theme: SidebarTheme }) {
const { t } = useTranslation();
const isCompactLayout = useIsCompactFormFactor();
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const handlePress = useCallback(() => {
if (isCompactLayout) showMobileAgent();
router.push(buildUsageRoute());
}, [isCompactLayout, showMobileAgent]);
return (
<FooterIconButton
onPress={handlePress}
testID="sidebar-usage"
label={t("settings.hostSections.usage")}
icon={Gauge}
theme={theme}
/>
);
}
function IconTooltipContent({
label,
shortcutKeys,
@@ -489,6 +512,7 @@ function SidebarFooter({
icon={Import}
theme={theme}
/>
<SidebarUsageButton theme={theme} />
<SidebarHelpMenu />
<FooterIconButton
onPress={handleSettings}
+17 -11
View File
@@ -625,13 +625,6 @@ function ModelRowProfileAction({
}) {
const isCompact = useIsCompactFormFactor();
const visible = hovered || isNative || isCompact;
const handlePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
onPress();
},
[onPress],
);
const pressableStyle = useCallback(
({ hovered: buttonHovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.rowIconButton,
@@ -645,7 +638,7 @@ function ModelRowProfileAction({
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
onPress={handlePress}
onPress={onPress}
hitSlop={8}
style={pressableStyle}
pointerEvents={visible ? "auto" : "none"}
@@ -778,7 +771,6 @@ function ModelRow({
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.browserRow,
styles.browserModelRow,
Boolean(hovered) && styles.browserRowHovered,
pressed && styles.browserRowPressed,
],
@@ -787,7 +779,7 @@ function ModelRow({
return (
<View
style={styles.modelRowHoverBoundary}
style={[styles.modelRowHoverBoundary, styles.browserModelRow]}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
@@ -815,10 +807,17 @@ function ModelRow({
<ThemedCheck size={ICON_SIZE.sm} uniProps={foregroundMutedMapping} />
) : null}
</View>
{profileAction}
{profileAction ? <View style={styles.rowIconButton} /> : null}
</View>
</View>
</ModelBrowserPressable>
{/* The row renders a <button> on web, so its profile action sits beside it,
over the slot reserved above, rather than inside it. */}
{profileAction ? (
<View style={styles.modelRowProfileActionSlot} pointerEvents="box-none">
{profileAction}
</View>
) : null}
</View>
);
}
@@ -1590,6 +1589,13 @@ const styles = StyleSheet.create((theme) => ({
modelRowHoverBoundary: {
position: "relative",
},
modelRowProfileActionSlot: {
position: "absolute",
top: 0,
bottom: 0,
right: isWeb ? theme.spacing[3] : theme.spacing[6],
justifyContent: "center",
},
browserModelRow: isWeb ? {} : { marginBottom: theme.spacing[1] },
browserRowHovered: {
backgroundColor: theme.colors.surface1,
@@ -0,0 +1,71 @@
import { useMemo, type ReactNode } from "react";
import { ScrollView, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { BackHeader } from "@/components/headers/back-header";
import { MenuHeader } from "@/components/headers/menu-header";
import { useIsCompactFormFactor } from "@/constants/layout";
interface PageLayoutProps {
title?: string;
/** Compact back button. Defaults to navigating back. */
onBack?: () => void;
testID?: string;
titleTestID?: string;
children: ReactNode;
}
/**
* A full-width page with a centered content column. On desktop the title is a
* document heading inside the page and the header only keeps the titlebar drag
* region and window controls; on compact the title moves into a back header.
*/
export function PageLayout({ title, onBack, testID, titleTestID, children }: PageLayoutProps) {
const isCompact = useIsCompactFormFactor();
const insets = useSafeAreaInsets();
const scrollContentStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]);
const showTitle = !isCompact && title !== undefined;
return (
<View style={styles.container}>
{isCompact ? <BackHeader title={title} onBack={onBack} /> : <MenuHeader borderless />}
<ScrollView style={styles.scroll} contentContainerStyle={scrollContentStyle} testID={testID}>
<View style={styles.content}>
{showTitle ? (
<View testID={titleTestID}>
<Text style={styles.title} testID="page-title">
{title}
</Text>
</View>
) : null}
{children}
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
scroll: {
flex: 1,
},
content: {
padding: theme.spacing[4],
paddingTop: theme.spacing[6],
width: "100%",
maxWidth: 720,
alignSelf: "center",
},
title: {
// Line up with the section titles, which sit inset from their cards.
marginLeft: theme.spacing[1],
marginBottom: theme.spacing[6],
fontSize: theme.fontSize["4xl"],
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
},
}));
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { PairingTargetTracker } from "./pair-link-credentials";
describe("pairing target password", () => {
it("clears the password and hides its input when switching hosts", () => {
const first = "relay://relay.example:443/srv_a?key=AAAA&ssl=true";
const second = "relay://relay.example:443/srv_b?key=BBBB&ssl=true";
const target = new PairingTargetTracker(first);
expect(target.changeUrl("relay://relay.example:443/")).toBe(false);
expect(target.changeUrl(second)).toBe(true);
expect(target.changeUrl(second)).toBe(false);
});
it("clears the direct form credential when its advanced URI changes to a relay target", () => {
const target = new PairingTargetTracker("", true);
expect(target.changeUrl("relay://relay.example:443/srv_new?key=BBBB&ssl=true")).toBe(true);
});
it("keeps the password when a relay URI receives harmless whitespace", () => {
const uri = "relay://relay.example:443/srv_a?key=AAAA&ssl=true";
const target = new PairingTargetTracker(uri);
expect(target.changeUrl(`${uri} `)).toBe(false);
});
it("recognizes a different host in an offer link after an incomplete edit", () => {
const offer = Buffer.from(
JSON.stringify({
v: 2,
serverId: "srv_b",
daemonPublicKeyB64: "BBBB",
relay: { endpoint: "relay.example:443" },
}),
).toString("base64url");
const target = new PairingTargetTracker("relay://relay.example:443/srv_a?key=AAAA");
expect(target.changeUrl("https://app.paseo.sh/#offer=")).toBe(false);
expect(target.changeUrl(`https://app.paseo.sh/#offer=${offer}`)).toBe(true);
});
});
@@ -0,0 +1,33 @@
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
function pairingTarget(url: string): string | null {
try {
const trimmed = url.trim();
const offer =
trimmed.startsWith("relay://") || trimmed.includes("#connect=")
? parseRelayConnectionUri(trimmed).offer
: parseConnectionOfferFromUrl(trimmed);
return offer ? `${offer.serverId}:${offer.daemonPublicKeyB64}` : null;
} catch {
// Keep the last valid target while the user edits an incomplete URI.
return null;
}
}
/** Tracks the last valid host so harmless edits never clear its password. */
export class PairingTargetTracker {
private currentTarget: string | null;
constructor(initialUrl = "", startsFromDirectForm = false) {
this.currentTarget = pairingTarget(initialUrl) ?? (startsFromDirectForm ? "direct-form" : null);
}
changeUrl(nextUrl: string): boolean {
const nextTarget = pairingTarget(nextUrl);
if (!nextTarget) return false;
const changed = this.currentTarget !== null && this.currentTarget !== nextTarget;
this.currentTarget = nextTarget;
return changed;
}
}
+121 -65
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -6,15 +6,27 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { Link } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@getpaseo/protocol/connection-offer";
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
import { PairingTargetTracker } from "./pair-link-credentials";
import { Button } from "@/components/ui/button";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
const FLEX_ONE_STYLE = { flex: 1 } as const;
function parsedHostLabel(input: string): string {
try {
if (input.startsWith("relay://") || input.includes("#connect=")) {
return parseRelayConnectionUri(input).offer.serverId;
}
return parseConnectionOfferFromUrl(input)?.serverId ?? "host";
} catch {
return "host";
}
}
const styles = StyleSheet.create((theme) => ({
helper: {
color: theme.colors.foregroundMuted,
@@ -50,6 +62,8 @@ const styles = StyleSheet.create((theme) => ({
export interface PairLinkModalProps {
visible: boolean;
initialUrl?: string;
initialPasswordRequired?: boolean;
onClose: () => void;
onCancel?: () => void;
onSaved?: (result: {
@@ -60,21 +74,57 @@ export interface PairLinkModalProps {
}) => void;
}
export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkModalProps) {
export function PairLinkModal({
visible,
initialUrl,
initialPasswordRequired = false,
onClose,
onCancel,
onSaved,
}: PairLinkModalProps) {
return (
<PairLinkModalContent
key={`${visible}:${initialUrl ?? ""}:${initialPasswordRequired}`}
visible={visible}
initialUrl={initialUrl}
initialPasswordRequired={initialPasswordRequired}
onClose={onClose}
onCancel={onCancel}
onSaved={onSaved}
/>
);
}
function PairLinkModalContent({
visible,
initialUrl,
initialPasswordRequired = false,
onClose,
onCancel,
onSaved,
}: PairLinkModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const { probeAndUpsertConnectionFromOfferUrl } = useHostMutations();
const isMobile = useIsCompactFormFactor();
const offerUrlRef = useRef("");
const offerUrlRef = useRef(initialUrl ?? "");
const targetTracker = useRef(new PairingTargetTracker(initialUrl));
const inputRef = useRef<EditingTextInputHandle>(null);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [password, setPassword] = useState("");
const [needsPassword, setNeedsPassword] = useState(initialPasswordRequired);
const [passwordResetKey, resetPasswordInput] = useReducer((key: number) => key + 1, 0);
const clearInput = useCallback(() => {
offerUrlRef.current = "";
targetTracker.current = new PairingTargetTracker();
inputRef.current?.replaceText("");
setPassword("");
setNeedsPassword(false);
resetPasswordInput();
}, []);
const pairIcon = useMemo(
@@ -96,74 +146,63 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
(onCancel ?? onClose)();
}, [isSaving, clearInput, onCancel, onClose]);
const handleSave = useCallback(async () => {
if (isSaving) return;
const raw = offerUrlRef.current.trim();
if (!raw) {
setErrorMessage(t("pairing.link.errors.required"));
return;
}
if (!raw.includes("#offer=")) {
setErrorMessage(t("pairing.link.errors.missingOffer"));
return;
}
const handleSave = useCallback(
async (input?: string) => {
if (isSaving) return;
const raw = (input ?? offerUrlRef.current).trim();
if (!raw) {
setErrorMessage(t("pairing.link.errors.required"));
return;
}
if (!raw.includes("#offer=") && !raw.startsWith("relay://") && !raw.includes("#connect=")) {
setErrorMessage(t("pairing.link.errors.missingOffer"));
return;
}
const parsedOffer = (() => {
try {
const idx = raw.indexOf("#offer=");
const encoded = raw.slice(idx + "#offer=".length).trim();
if (!encoded) {
throw new Error(t("pairing.link.errors.emptyOffer"));
}
const payload = decodeOfferFragmentPayload(encoded);
return ConnectionOfferSchema.parse(payload);
setIsSaving(true);
setErrorMessage("");
const { profile, serverId, hostname } = await probeAndUpsertConnectionFromOfferUrl(
raw,
password || undefined,
);
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
onSaved?.({ profile, serverId, hostname, isNewHost });
handleClose();
} catch (error) {
const message = error instanceof Error ? error.message : t("pairing.link.errors.invalid");
const message =
error instanceof Error ? error.message : t("pairing.link.errors.unableToPair");
setErrorMessage(message);
if (getConnectionAuthFailureReason(error)) {
setNeedsPassword(true);
return;
}
if (!isMobile) {
Alert.alert(t("pairing.link.alert.failedTitle"), message);
}
return null;
} finally {
setIsSaving(false);
}
})();
if (!parsedOffer) {
return;
}
try {
setIsSaving(true);
setErrorMessage("");
const { client, hostname } = await connectToDaemon(
{
id: "probe",
type: "relay",
relayEndpoint: normalizeHostPort(parsedOffer.relay.endpoint),
useTls: parsedOffer.relay.useTls,
daemonPublicKeyB64: parsedOffer.daemonPublicKeyB64,
},
{ serverId: parsedOffer.serverId },
);
await client.close().catch(() => undefined);
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
const profile = await upsertDaemonFromOfferUrl(raw, hostname ?? undefined);
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
handleClose();
} catch (error) {
const message =
error instanceof Error ? error.message : t("pairing.link.errors.unableToPair");
setErrorMessage(message);
if (!isMobile) {
Alert.alert(t("pairing.link.alert.failedTitle"), message);
}
} finally {
setIsSaving(false);
}
}, [daemons, handleClose, isMobile, isSaving, onSaved, t, upsertDaemonFromOfferUrl]);
},
[
daemons,
handleClose,
isMobile,
isSaving,
onSaved,
password,
t,
probeAndUpsertConnectionFromOfferUrl,
],
);
const handleChangeOfferUrl = useCallback((next: string) => {
if (targetTracker.current.changeUrl(next)) {
setPassword("");
setNeedsPassword(false);
resetPasswordInput();
setErrorMessage("");
}
offerUrlRef.current = next;
}, []);
@@ -186,6 +225,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
<Text style={styles.label}>{t("pairing.link.label")}</Text>
<AdaptiveTextInput
ref={inputRef}
initialValue={initialUrl}
testID="pair-link-input"
nativeID="pair-link-input"
accessibilityLabel={t("pairing.link.label")}
@@ -201,6 +241,22 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
</View>
{needsPassword ? (
<View style={styles.field}>
<Text style={styles.label}>
{t("pairing.hostPassword.title", { host: parsedHostLabel(offerUrlRef.current) })}
</Text>
<AdaptiveTextInput
testID="pair-link-password-input"
resetKey={`pair-link-password-${passwordResetKey}`}
accessibilityLabel={t("pairing.hostPassword.label")}
onChangeText={setPassword}
secureTextEntry
style={styles.input}
/>
</View>
) : null}
<View style={styles.actions}>
<Button
style={FLEX_ONE_STYLE}
@@ -19,10 +19,10 @@ import { useToast } from "@/contexts/toast-context";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useTimeAgo } from "@/hooks/use-time-ago";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { settingsStyles } from "@/styles/settings";
import { resolveProviderLabel } from "@/utils/provider-definitions";
import { formatTimeAgo } from "@/utils/time";
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
import type { ProviderProfileModel } from "@getpaseo/protocol/provider-config";
@@ -409,7 +409,7 @@ interface ProviderModalBodyProps {
}
interface ProviderSheetFooterInput {
fetchedAtLabel: string | null;
fetchedAt: string | null;
isCompact: boolean;
modelsRefreshing: boolean;
t: TFunction;
@@ -419,7 +419,7 @@ interface ProviderSheetFooterInput {
}
function renderProviderSheetFooter({
fetchedAtLabel,
fetchedAt,
isCompact,
modelsRefreshing,
t,
@@ -430,16 +430,10 @@ function renderProviderSheetFooter({
const contentStyle = isCompact ? sheetStyles.compactFooterContent : sheetStyles.footerContent;
const actionsStyle = isCompact ? sheetStyles.compactFooterActions : sheetStyles.footerActions;
const buttonStyle = isCompact ? sheetStyles.compactFooterButton : null;
const metaStyle = isCompact
? [sheetStyles.footerMeta, sheetStyles.compactFooterMeta]
: sheetStyles.footerMeta;
return (
<View style={contentStyle}>
{fetchedAtLabel || !isCompact ? (
<Text style={metaStyle} numberOfLines={1}>
{fetchedAtLabel ? t("settings.providers.models.updated", { time: fetchedAtLabel }) : ""}
</Text>
{fetchedAt || !isCompact ? (
<ProviderUpdatedLabel fetchedAt={fetchedAt} isCompact={isCompact} />
) : null}
<View style={actionsStyle}>
<Button
@@ -477,6 +471,25 @@ function renderProviderSheetFooter({
);
}
function ProviderUpdatedLabel({
fetchedAt,
isCompact,
}: {
fetchedAt: string | null;
isCompact: boolean;
}) {
const { t } = useTranslation();
const label = useTimeAgo(fetchedAt ? new Date(fetchedAt) : null);
const style = isCompact
? [sheetStyles.footerMeta, sheetStyles.compactFooterMeta]
: sheetStyles.footerMeta;
return (
<Text style={style} numberOfLines={1}>
{fetchedAt ? t("settings.providers.models.updated", { time: label }) : ""}
</Text>
);
}
function ProviderModalBody(props: ProviderModalBodyProps) {
const { t } = useTranslation();
const {
@@ -609,18 +622,6 @@ export function ProviderDiagnosticSheet({
});
stableDiscoveredRef.current = nextDiscoveredCache;
const [clockTick, setClockTick] = useState(0);
useEffect(() => {
if (!visible) return;
const id = setInterval(() => setClockTick((tick) => tick + 1), 10_000);
return () => clearInterval(id);
}, [visible]);
const fetchedAtLabel = useMemo(() => {
if (!providerEntry?.fetchedAt) return null;
void clockTick;
return formatTimeAgo(new Date(providerEntry.fetchedAt));
}, [providerEntry?.fetchedAt, clockTick]);
useEffect(() => {
if (!visible) {
setQuery("");
@@ -686,7 +687,7 @@ export function ProviderDiagnosticSheet({
onClose={onClose}
testID="provider-settings-sheet"
footer={renderProviderSheetFooter({
fetchedAtLabel,
fetchedAt: providerEntry?.fetchedAt ?? null,
isCompact,
modelsRefreshing,
t,
@@ -36,14 +36,14 @@ const CATALOG_ICON_SVGS = new Map(
const catalogIconComponents = new Map<string, ProviderIconComponent>();
const snapshotIconComponents = new Map<string, { svg: string; component: ProviderIconComponent }>();
/** Renders a sanitized SVG string from a host catalog, tinted through `currentColor`. */
export function SvgIcon({ svg, size, color }: ProviderIconProps & { svg: string }) {
return createElement(SvgXml, { xml: svg, width: size, height: size, color });
}
function createSvgIcon(provider: string, iconSvg: string): ProviderIconComponent {
const SvgProviderIcon: ProviderIconComponent = ({ size, color }) =>
createElement(SvgXml, {
xml: iconSvg,
width: size,
height: size,
color,
});
SvgIcon({ svg: iconSvg, size, color });
SvgProviderIcon.displayName = `SvgProviderIcon(${provider})`;
return SvgProviderIcon;
}
@@ -22,7 +22,7 @@ import {
resolveScheduleTitle,
scheduleProductName,
} from "@/utils/schedule-format";
import { formatTimeAgo } from "@/utils/time";
import { useTimeAgo } from "@/hooks/use-time-ago";
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
// Themed lucide wrappers — module-scope so only the icon re-renders on theme
@@ -96,16 +96,19 @@ function stateBadge(state: ScheduleDerivedState): {
// Meta reads left-to-right as identity → history → future: how often, when it
// was created, when it last ran, and (only while it can still run) when it runs
// next. Status lives on the badge, never repeated here.
function buildMeta(
schedule: ScheduleSummary,
state: ScheduleDerivedState,
serverName: string | undefined,
singleHost: boolean,
): string {
function buildMeta(input: {
schedule: ScheduleSummary;
state: ScheduleDerivedState;
createdAgo: string;
lastRunAgo: string;
serverName: string | undefined;
singleHost: boolean;
}): string {
const { schedule, state, serverName, singleHost } = input;
const parts = [
formatCadence(schedule.cadence),
`Created ${formatTimeAgo(new Date(schedule.createdAt))}`,
schedule.lastRunAt ? `Last run ${formatTimeAgo(new Date(schedule.lastRunAt))}` : "Never run",
`Created ${input.createdAgo}`,
schedule.lastRunAt ? `Last run ${input.lastRunAgo}` : "Never run",
];
if (state === "active") {
const next = formatNextRun(schedule.nextRunAt);
@@ -119,6 +122,27 @@ function buildMeta(
return parts.join(" · ");
}
function ScheduleMeta({
schedule,
state,
serverName,
singleHost,
}: {
schedule: ScheduleSummary;
state: ScheduleDerivedState;
serverName: string | undefined;
singleHost: boolean;
}) {
const createdAgo = useTimeAgo(new Date(schedule.createdAt));
const lastRunAgo = useTimeAgo(schedule.lastRunAt ? new Date(schedule.lastRunAt) : null);
const meta = buildMeta({ schedule, state, createdAgo, lastRunAgo, serverName, singleHost });
return (
<Text style={settingsStyles.rowHint} numberOfLines={1}>
{meta}
</Text>
);
}
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
* dynamic component (getProviderIcon) stays compliant without useUnistyles. */
function ProviderGlyph({
@@ -168,7 +192,6 @@ export function ScheduleRow({
const title = resolveScheduleTitle(schedule);
const productName = scheduleProductName(schedule);
const badge = stateBadge(state);
const meta = buildMeta(schedule, state, serverName, singleHost ?? false);
const canRun = schedule.target.type === "new-agent" && (state === "active" || state === "paused");
const rowStyle = useCallback(
@@ -206,9 +229,12 @@ export function ScheduleRow({
<Text style={styles.target} numberOfLines={1}>
{targetLabel}
</Text>
<Text style={settingsStyles.rowHint} numberOfLines={1}>
{meta}
</Text>
<ScheduleMeta
schedule={schedule}
state={state}
serverName={serverName}
singleHost={singleHost ?? false}
/>
</View>
</View>
@@ -117,17 +117,16 @@ export function SettingsSelect<Value extends string>({
disabled,
...row
}: SettingsSelectProps<Value>) {
const selectedLabel = options.find((option) => option.value === value)?.label ?? value;
return (
<SettingsRow {...row}>
<DropdownMenu>
<DropdownTrigger
disabled={disabled}
accessibilityRole="button"
accessibilityLabel={row.label}
accessibilityLabel={`${row.label}: ${selectedLabel}`}
>
<Text style={styles.value}>
{options.find((option) => option.value === value)?.label ?? value}
</Text>
{selectedLabel}
</DropdownTrigger>
<DropdownMenuContent side="bottom" align="end" width={220}>
{options.map((option) => (
@@ -203,6 +202,5 @@ const styles = StyleSheet.create((theme) => ({
},
accessoryLabel: { flexShrink: 1, minWidth: 0 },
control: { flexShrink: 1, maxWidth: "100%" },
value: { color: theme.colors.foreground, fontSize: theme.fontSize.base },
input: { minWidth: 180 },
}));
@@ -24,7 +24,7 @@ interface SidebarHeaderRowProps {
/**
* "header" (default): a sidebar-height row with its own bottom separator —
* the lone header at the top of a sidebar (settings "Back to workspace").
* "compact": a workspace-row-height row with no separator, for entries that
* "compact": a row with no separator, for entries that
* sit in a header group whose wrapper owns the single divider.
*/
variant?: SidebarHeaderRowVariant;
@@ -52,10 +52,9 @@ export function SidebarHeaderRow({
const buttonStyle = useCallback(
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.button,
variant === "compact" && styles.buttonCompact,
(Boolean(hovered) || isActive) && styles.buttonHovered,
],
[isActive, variant],
[isActive],
);
const renderChildren = useCallback(
@@ -64,7 +63,7 @@ export function SidebarHeaderRow({
return (
<>
<ThemedIcon
size={ICON_SIZE.sm}
size={variant === "compact" ? ICON_SIZE.sm : ICON_SIZE.md}
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping}
/>
<SidebarHeaderRowLabel label={label} isHighlighted={isHighlighted} />
@@ -74,7 +73,7 @@ export function SidebarHeaderRow({
</>
);
},
[ThemedIcon, isActive, label, shortcutKeys],
[ThemedIcon, isActive, label, shortcutKeys, variant],
);
return (
@@ -129,21 +128,14 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
// Match the sidebar workspace-row shape (height, padding, radius) so the
// compact header entries sit tight against the workspace list below.
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
},
// Compact header entries (New workspace / History) sit tighter than the
// workspace-row shape the base button mirrors.
buttonCompact: {
minHeight: 32,
paddingVertical: theme.spacing[1.5],
// Same row geometry as the settings sidebar items. Shorter than the header
// strip so the hover highlight clears the strip's bottom separator.
minHeight: 28,
paddingVertical: theme.spacing[1],
// Match the project rows' inner padding so the icons align on one vertical
// edge with the workspace list below (base button uses a wider spacing[3]).
// edge with the list below.
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
},
buttonHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
@@ -4,7 +4,7 @@ import { DiffStat } from "@/components/diff-stat";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useAppSettings } from "@/hooks/use-settings";
import type { SidebarWorkspaceTrailing } from "@/hooks/use-settings";
import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago";
import { useCompactTimeAgo } from "@/hooks/use-time-ago";
export type { SidebarWorkspaceTrailing };
+135 -65
View File
@@ -2,14 +2,21 @@ import { AlertTriangle, CheckCircle2, Info, XCircle, type LucideIcon } from "luc
import { type ReactNode, useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
type ButtonControlSize,
buttonIconSize,
createControlGeometry,
} from "@/components/ui/control-geometry";
import { hexColorWithAlpha } from "@/utils/color";
export type AlertVariant = "default" | "info" | "success" | "warning" | "error";
export type AlertSize = ButtonControlSize;
export interface AlertProps {
title?: string;
description?: ReactNode;
variant?: AlertVariant;
icon?: ReactNode;
size?: AlertSize;
children?: ReactNode;
testID?: string;
}
@@ -25,50 +32,93 @@ export function Alert({
title,
description,
variant = "default",
icon,
size = "md",
children,
testID,
}: AlertProps) {
const { theme } = useUnistyles();
const accentColor = resolveAccentColor(variant, theme);
const borderColor = variant === "success" ? theme.colors.border : accentColor;
const borderColor =
variant === "success" || !accentColor
? theme.colors.border
: hexColorWithAlpha(accentColor, 0.5);
const containerStyle = useMemo(
() => [styles.container, borderColor ? { borderColor } : null],
[borderColor],
);
const titleStyle = useMemo(
() => [styles.title, accentColor ? { color: accentColor } : null],
[accentColor],
);
const sized = resolveSizeStyles(size);
const containerStyle = [styles.container, sized.container, { borderColor }];
const titleStyle = [styles.title, sized.text, accentColor ? { color: accentColor } : null];
const iconSize = buttonIconSize[size];
const resolvedIcon = useMemo(() => {
if (icon !== undefined) return icon;
if (variant === "default") return null;
const Icon = VARIANT_ICON[variant];
return <Icon size={theme.iconSize.sm} color={accentColor ?? theme.colors.foreground} />;
}, [icon, variant, theme, accentColor]);
return <Icon size={iconSize} color={accentColor ?? theme.colors.foreground} />;
}, [variant, theme, accentColor, iconSize]);
const hasDescription = description != null && description !== "";
let descriptionContent: ReactNode = null;
if (typeof description === "string" && description !== "") {
descriptionContent = <Text style={[styles.description, sized.text]}>{description}</Text>;
} else if (description != null && description !== "") {
descriptionContent = <View style={styles.descriptionSlot}>{description}</View>;
}
const leadContent = title ? <Text style={titleStyle}>{title}</Text> : descriptionContent;
const belowLead = title ? descriptionContent : null;
const hasBody = belowLead !== null || Boolean(children);
return (
<View style={containerStyle} testID={testID} accessibilityRole="alert">
{resolvedIcon ? <View style={styles.iconSlot}>{resolvedIcon}</View> : null}
<View style={styles.body}>
{title ? <Text style={titleStyle}>{title}</Text> : null}
{hasDescription && typeof description === "string" ? (
<Text style={styles.description}>{description}</Text>
<View style={[styles.lead, sized.lead]}>
{resolvedIcon ? (
<View style={[styles.iconSlot, sized.iconSlot]}>{resolvedIcon}</View>
) : null}
{hasDescription && typeof description !== "string" ? (
<View style={styles.descriptionSlot}>{description}</View>
) : null}
{children ? <View style={styles.actions}>{children}</View> : null}
{leadContent ? <View style={styles.leadText}>{leadContent}</View> : null}
</View>
{hasBody ? (
<View style={resolvedIcon ? sized.indent : null}>
{belowLead}
{children ? <View style={styles.actions}>{children}</View> : null}
</View>
) : null}
</View>
);
}
function resolveSizeStyles(size: AlertSize) {
if (size === "xs") {
return {
container: styles.containerXs,
text: styles.textXs,
lead: styles.leadXs,
iconSlot: styles.iconSlotXs,
indent: styles.indentXs,
};
}
if (size === "sm") {
return {
container: styles.containerSm,
text: styles.textSm,
lead: styles.leadSm,
iconSlot: styles.iconSlotSm,
indent: styles.indentSm,
};
}
if (size === "lg") {
return {
container: styles.containerLg,
text: styles.textLg,
lead: styles.leadLg,
iconSlot: styles.iconSlotLg,
indent: styles.indentLg,
};
}
return {
container: styles.containerMd,
text: styles.textMd,
lead: styles.leadMd,
iconSlot: styles.iconSlotMd,
indent: styles.indentMd,
};
}
function resolveAccentColor(
variant: AlertVariant,
theme: ReturnType<typeof useUnistyles>["theme"],
@@ -80,43 +130,63 @@ function resolveAccentColor(
return null;
}
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[3],
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: "transparent",
borderRadius: theme.borderRadius.xl,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
},
iconSlot: {
paddingTop: 2,
},
body: {
flex: 1,
minWidth: 0,
gap: theme.spacing[1],
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
},
description: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
descriptionSlot: {
flexShrink: 1,
minWidth: 0,
gap: theme.spacing[2],
},
actions: {
flexDirection: "row",
gap: theme.spacing[2],
marginTop: theme.spacing[2],
},
}));
const styles = StyleSheet.create((theme) => {
const { alert } = createControlGeometry(theme);
return {
container: {
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: "transparent",
},
containerXs: alert.xs.container,
containerSm: alert.sm.container,
containerMd: alert.md.container,
containerLg: alert.lg.container,
lead: {
flexDirection: "row",
alignItems: "center",
},
iconSlot: {
alignItems: "center",
},
leadText: {
flex: 1,
minWidth: 0,
},
leadXs: alert.xs.lead,
leadSm: alert.sm.lead,
leadMd: alert.md.lead,
leadLg: alert.lg.lead,
iconSlotXs: alert.xs.iconSlot,
iconSlotSm: alert.sm.iconSlot,
iconSlotMd: alert.md.iconSlot,
iconSlotLg: alert.lg.iconSlot,
indentXs: alert.xs.indent,
indentSm: alert.sm.indent,
indentMd: alert.md.indent,
indentLg: alert.lg.indent,
textXs: alert.xs.text,
textSm: alert.sm.text,
textMd: alert.md.text,
textLg: alert.lg.text,
title: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium,
},
description: {
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.normal,
},
descriptionSlot: {
flexShrink: 1,
minWidth: 0,
gap: theme.spacing[2],
},
actions: {
flexDirection: "row",
gap: theme.spacing[2],
marginTop: theme.spacing[2],
},
};
});
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
buttonControlHeight,
buttonIconSize,
createControlGeometry,
getControlInteractionPhase,
} from "@/components/ui/control-geometry";
@@ -11,6 +12,7 @@ const theme = {
md: 6,
lg: 8,
xl: 12,
"2xl": 16,
full: 9999,
},
borderWidth: {
@@ -30,10 +32,13 @@ const theme = {
},
spacing: {
0: 0,
0.5: 2,
1: 4,
2: 8,
3: 12,
4: 16,
6: 24,
8: 32,
},
} as unknown as Theme;
@@ -157,4 +162,41 @@ describe("control geometry", () => {
geometry.buttonMd.paddingHorizontal,
);
});
it("gives alert title and description one font size and a tight gap per size", () => {
const { alert } = createControlGeometry(theme);
expect(alert.xs.text).toEqual({ fontSize: 12 });
expect(alert.sm.text).toEqual({ fontSize: 14 });
expect(alert.md.text).toEqual(alert.sm.text);
expect(alert.lg.text).toEqual(alert.sm.text);
for (const size of ["xs", "sm", "md", "lg"] as const) {
expect(alert[size].container.gap).toBeLessThanOrEqual(4);
}
});
it("starts alert text below the lead row at the lead text's left edge", () => {
const { alert } = createControlGeometry(theme);
for (const size of ["xs", "sm", "md", "lg"] as const) {
expect(alert[size].iconSlot.width).toBe(buttonIconSize[size]);
expect(alert[size].indent.marginLeft).toBe(alert[size].iconSlot.width + alert[size].lead.gap);
}
});
it("keeps sm alerts as roomy and round as the original alert, and scales the rest around it", () => {
const { alert } = createControlGeometry(theme);
expect(alert.sm.container).toMatchObject({
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 16,
});
expect(alert.xs.container.paddingVertical).toBeLessThan(alert.sm.container.paddingVertical);
expect(alert.md.container.paddingVertical).toBeGreaterThan(alert.sm.container.paddingVertical);
expect(alert.lg.container.paddingVertical).toBeGreaterThan(alert.md.container.paddingVertical);
for (const size of ["xs", "sm", "md", "lg"] as const) {
expect(alert[size].container.borderRadius).toBeGreaterThanOrEqual(12);
}
});
});
@@ -87,6 +87,77 @@ function fieldVerticalPadding(
return (controlHeight - lineHeight - borderWidth * 2) / 2;
}
/**
* Alerts share the button's size names, text size, and icon size (`buttonIconSize`).
* The icon and the first text sit in one centered lead row; everything below is indented
* by the icon slot plus its gap so it starts at the lead text's left edge. Title and
* description use one font size; only the container gap separates them.
*/
function createAlertGeometry(theme: Theme) {
function alertSize(input: {
size: ButtonControlSize;
fontSize: number;
paddingVertical: number;
paddingHorizontal: number;
borderRadius: number;
iconGap: number;
textGap: number;
}) {
const iconSize = buttonIconSize[input.size];
return {
container: {
paddingVertical: input.paddingVertical,
paddingHorizontal: input.paddingHorizontal,
borderRadius: input.borderRadius,
gap: input.textGap,
},
lead: { gap: input.iconGap },
iconSlot: { width: iconSize },
indent: { marginLeft: iconSize + input.iconGap },
text: { fontSize: input.fontSize },
};
}
return {
xs: alertSize({
size: "xs",
fontSize: theme.fontSize.sm,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.xl,
iconGap: theme.spacing[2],
textGap: 0,
}),
sm: alertSize({
size: "sm",
fontSize: theme.fontSize.base,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius["2xl"],
iconGap: theme.spacing[3],
textGap: theme.spacing[0.5],
}),
md: alertSize({
size: "md",
fontSize: theme.fontSize.base,
paddingVertical: theme.spacing[4],
paddingHorizontal: theme.spacing[6],
borderRadius: theme.borderRadius["2xl"],
iconGap: theme.spacing[3],
textGap: theme.spacing[0.5],
}),
lg: alertSize({
size: "lg",
fontSize: theme.fontSize.base,
paddingVertical: theme.spacing[6],
paddingHorizontal: theme.spacing[8],
borderRadius: theme.borderRadius["2xl"],
iconGap: theme.spacing[4],
textGap: theme.spacing[1],
}),
} satisfies Record<ButtonControlSize, unknown>;
}
export function getControlInteractionPhase(
state: ControlInteractionState,
): ControlInteractionPhase {
@@ -147,6 +218,7 @@ export function createControlGeometry(theme: Theme) {
fontSize: theme.fontSize.base,
lineHeight: fieldTextMdLineHeight,
};
const alert = createAlertGeometry(theme);
const switchControl = {
minHeight: CONTROL_HEIGHTS.compact,
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
@@ -156,22 +228,22 @@ export function createControlGeometry(theme: Theme) {
buttonXs: {
minHeight: buttonControlHeight.xs,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderRadius: theme.borderRadius.xl,
},
buttonSm: {
minHeight: buttonControlHeight.sm,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderRadius: theme.borderRadius.xl,
},
buttonMd: {
minHeight: buttonControlHeight.md,
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
borderRadius: theme.borderRadius["2xl"],
},
buttonLg: {
minHeight: buttonControlHeight.lg,
paddingHorizontal: theme.spacing[6],
borderRadius: theme.borderRadius.xl,
borderRadius: theme.borderRadius["2xl"],
},
buttonText: {
fontSize: theme.fontSize.base,
@@ -179,6 +251,7 @@ export function createControlGeometry(theme: Theme) {
buttonTextXs: {
fontSize: theme.fontSize.sm,
},
alert,
formTextInputSm: {
...fieldControlSm,
...fieldTextSm,
@@ -233,17 +306,17 @@ export function createControlGeometry(theme: Theme) {
segmentedSegmentXs: {
minHeight: CONTROL_HEIGHTS.tight - SEGMENTED_TIGHT_INSET * 2,
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderRadius: theme.borderRadius.xl,
},
segmentedSegmentSm: {
minHeight: CONTROL_HEIGHTS.compact - SEGMENTED_COMPACT_INSET * 2,
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderRadius: theme.borderRadius.xl,
},
segmentedSegmentMd: {
minHeight: CONTROL_HEIGHTS.field - SEGMENTED_FIELD_INSET * 2,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
borderRadius: theme.borderRadius["2xl"],
},
segmentedLabelXs: {
fontSize: theme.fontSize.sm,
@@ -1,48 +1,104 @@
import type { ReactElement, ReactNode } from "react";
import { View } from "react-native";
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
import { Text, View, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ChevronDown } from "lucide-react-native";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
buttonIconSize,
createControlGeometry,
type ButtonControlSize,
} from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
import { DropdownMenuTrigger, type DropdownMenuTriggerProps } from "@/components/ui/dropdown-menu";
const ThemedChevronDown = withUnistyles(ChevronDown);
interface DropdownTriggerProps extends Omit<DropdownMenuTriggerProps, "children"> {
children?: ReactNode;
chevron?: ReactNode | null;
}
const chevronColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
interface DropdownTriggerProps extends Omit<DropdownMenuTriggerProps, "children" | "style"> {
/** The selected option's label. */
children: string;
/** Icon or swatch before the label. */
leading?: ReactNode;
size?: ButtonControlSize;
}
/**
* A select-style menu trigger: the selected value plus a chevron, drawn with the
* outline Button's chrome and size scale so a dropdown and a button in the same
* row share one geometry. Callers pick a size; they don't style it.
*/
export function DropdownTrigger({
children,
chevron,
leading,
size = "sm",
disabled,
...props
}: DropdownTriggerProps): ReactElement {
let sizeStyle: ViewStyle;
if (size === "xs") {
sizeStyle = styles.xs;
} else if (size === "md") {
sizeStyle = styles.md;
} else if (size === "lg") {
sizeStyle = styles.lg;
} else {
sizeStyle = styles.sm;
}
const triggerStyle = useCallback(
({ pressed }: { pressed: boolean }) => [
styles.base,
sizeStyle,
pressed ? styles.pressed : null,
disabled ? styles.disabled : null,
],
[disabled, sizeStyle],
);
const labelStyle = useMemo(() => [styles.label, size === "xs" && styles.labelXs], [size]);
return (
<DropdownMenuTrigger {...props}>
<View style={styles.row}>
<DropdownMenuTrigger {...props} disabled={disabled} style={triggerStyle}>
{leading}
<Text style={labelStyle} numberOfLines={1}>
{children}
{chevron !== null &&
(chevron ?? (
<View style={styles.chevronContainer}>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={chevronColorMapping} />
</View>
))}
</Text>
<View style={styles.chevron}>
<ThemedChevronDown size={buttonIconSize[size]} uniProps={chevronColorMapping} />
</View>
</DropdownMenuTrigger>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
chevronContainer: {
transform: [{ translateY: 1 }],
},
}));
const styles = StyleSheet.create((theme) => {
const geometry = createControlGeometry(theme);
return {
base: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
borderWidth: 1,
borderColor: theme.colors.borderAccent,
backgroundColor: "transparent",
},
xs: geometry.buttonXs,
sm: geometry.buttonSm,
md: geometry.buttonMd,
lg: geometry.buttonLg,
pressed: {
opacity: 0.85,
},
disabled: {
opacity: theme.opacity[50],
},
label: {
flexShrink: 1,
color: theme.colors.foreground,
...geometry.buttonText,
},
labelXs: geometry.buttonTextXs,
chevron: {
transform: [{ translateY: 1 }],
},
};
});
@@ -56,6 +56,8 @@ interface ToolbarButtonCommonProps extends Omit<
> {
children: ReactNode;
label: string;
/** What the tooltip says when it is not the label, e.g. a status. Defaults to the label. */
tooltip?: ReactNode;
selected?: boolean;
compact?: boolean;
shortcut?: ShortcutKey[][] | null;
@@ -76,6 +78,7 @@ type ToolbarButtonProps = ToolbarButtonCommonProps &
export function ToolbarButton({
children,
label,
tooltip: tooltipBody,
selected = false,
compact = false,
shortcut,
@@ -105,7 +108,7 @@ export function ToolbarButton({
const tooltip = (
<TooltipContent side={tooltipSide} align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{label}</Text>
{tooltipBody ?? <Text style={styles.tooltipText}>{label}</Text>}
{shortcut ? <Shortcut chord={shortcut} /> : null}
</View>
</TooltipContent>
@@ -32,15 +32,32 @@ afterEach(() => {
}
});
function parseColor(value: string): { rgb: string; alpha: number } {
const channels = value.match(/[\d.]+/g)?.map(Number) ?? [];
return { rgb: channels.slice(0, 3).join(","), alpha: channels[3] ?? 1 };
}
describe("StatusBadge", () => {
it.each(["success", "warning", "error", "muted"] as const)(
"uses the semantic badge shell for the %s variant",
it("uses the neutral badge shell for the muted variant", () => {
const style = getComputedStyle(mountBadge("muted"));
expect(style.backgroundColor).toBe("rgb(228, 228, 231)");
expect(style.borderColor).toBe("rgb(228, 228, 231)");
});
it.each(["success", "warning", "error"] as const)(
"fills the %s variant with a translucent tint of its own status color",
(variant) => {
const badge = mountBadge(variant);
const style = getComputedStyle(badge);
const text = badge.lastElementChild;
if (!(text instanceof HTMLElement)) {
throw new Error("StatusBadge did not render its label");
}
const fill = parseColor(getComputedStyle(badge).backgroundColor);
expect(style.backgroundColor).toBe("rgb(228, 228, 231)");
expect(style.borderColor).toBe("rgb(228, 228, 231)");
expect(fill.rgb).toBe(parseColor(getComputedStyle(text).color).rgb);
expect(fill.alpha).toBeGreaterThan(0);
expect(fill.alpha).toBeLessThan(0.5);
},
);
@@ -11,6 +11,15 @@ interface StatusBadgeProps {
}
export function StatusBadge({ label, variant = "muted", leading }: StatusBadgeProps) {
const pillStyle = useMemo(
() => [
styles.pill,
variant === "success" && styles.pillSuccess,
variant === "warning" && styles.pillWarning,
variant === "error" && styles.pillError,
],
[variant],
);
const textStyle = useMemo(
() => [
styles.pillText,
@@ -22,7 +31,7 @@ export function StatusBadge({ label, variant = "muted", leading }: StatusBadgePr
);
return (
<View style={styles.pill}>
<View style={pillStyle}>
{leading}
<Text style={textStyle}>{label}</Text>
</View>
@@ -41,6 +50,18 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2],
paddingVertical: 3,
},
pillSuccess: {
backgroundColor: theme.colors.statusSuccessTint,
borderColor: "transparent",
},
pillWarning: {
backgroundColor: theme.colors.statusWarningTint,
borderColor: "transparent",
},
pillError: {
backgroundColor: theme.colors.statusDangerTint,
borderColor: "transparent",
},
pillText: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
@@ -462,7 +462,7 @@ export function WorkspaceSetupDialog() {
cwd={sourceDirectory}
clearDraft={chatDraft.clear}
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
commandDraft={composerState?.commandDraft}
agentControls={agentControlsWithDisabled}
inputWrapperStyle={styles.composerInputWrapper}
/>
@@ -198,12 +198,15 @@ describe("useAgentInputDraft live contract", () => {
});
expect(getLatest().composerState?.agentControls.selectedProvider).toBe("codex");
expect(getLatest().composerState?.commandDraftConfig).toEqual({
provider: "codex",
cwd: "/repo",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
expect(getLatest().composerState?.commandDraft).toEqual({
status: "ready",
config: {
provider: "codex",
cwd: "/repo",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
},
});
const hydratedTextReplacement = getLatest().textReplacement;
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { hasDraftContent, resolveDraftKey } from "./input-draft-core";
import {
buildDraftCommandConfig,
buildDraftCommandTarget,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
} from "@/provider-selection/provider-selection";
@@ -124,46 +124,55 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
});
});
describe("buildDraftComposerCommandConfig", () => {
it("returns undefined when cwd is empty", () => {
describe("buildDraftCommandTarget", () => {
const codexSelection = {
provider: "codex",
modelId: "gpt-5.4",
modeId: "auto",
thinkingOptionId: "high",
availableModels: [],
modeOptions: [{ id: "auto", label: "Auto" }],
};
it("needs a project when cwd is empty", () => {
expect(
buildDraftCommandConfig({
selection: {
provider: "codex",
modelId: "gpt-5.4",
modeId: "",
thinkingOptionId: "",
availableModels: [],
modeOptions: [],
},
buildDraftCommandTarget({
selection: codexSelection,
cwd: " ",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
).toBeUndefined();
).toEqual({ status: "needs-project" });
});
it("needs a provider when none is selected", () => {
expect(
buildDraftCommandTarget({
selection: { ...codexSelection, provider: null },
cwd: "/repo",
effectiveModelId: "",
effectiveThinkingOptionId: "",
}),
).toEqual({ status: "needs-provider" });
});
it("builds the draft command config from derived composer state", () => {
expect(
buildDraftCommandConfig({
selection: {
provider: "codex",
modelId: "gpt-5.4",
modeId: "auto",
thinkingOptionId: "high",
availableModels: [],
modeOptions: [{ id: "auto", label: "Auto" }],
},
buildDraftCommandTarget({
selection: codexSelection,
cwd: "/repo",
effectiveModelId: "gpt-5.4",
effectiveThinkingOptionId: "high",
}),
).toEqual({
provider: "codex",
cwd: "/repo",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
status: "ready",
config: {
provider: "codex",
cwd: "/repo",
modeId: "auto",
model: "gpt-5.4",
thinkingOptionId: "high",
},
});
});
});
+14 -18
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { UserComposerAttachment } from "@/attachments/types";
import type { TextReplacement } from "@/composer/types";
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
import {
useAgentFormState,
type CreateAgentInitialValues,
@@ -16,7 +16,7 @@ import {
type DraftKeyInput,
} from "@/composer/draft/input-draft-core";
import {
buildDraftCommandConfig,
buildDraftCommandTarget,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
type ProviderSelectionState,
@@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & {
effectiveThinkingOptionId: string;
featureValues: Record<string, unknown> | undefined;
agentControls: DraftAgentControlsProps;
commandDraftConfig: DraftCommandConfig | undefined;
commandDraft: DraftCommandTarget;
};
export interface AgentInputDraft {
@@ -287,19 +287,16 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
[applyProfileFeatureValues, formState],
);
const commandDraftConfig = useMemo(
const commandDraft = useMemo(
() =>
composerOptions
? buildDraftCommandConfig({
selection: providerSelection,
cwd: workingDir,
effectiveModelId,
effectiveThinkingOptionId,
featureValues: draftFeatureValues,
})
: undefined,
buildDraftCommandTarget({
selection: providerSelection,
cwd: workingDir,
effectiveModelId,
effectiveThinkingOptionId,
featureValues: draftFeatureValues,
}),
[
composerOptions,
effectiveModelId,
effectiveThinkingOptionId,
draftFeatureValues,
@@ -325,10 +322,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
onSetFeature: setDraftFeatureValue,
onApplyAgentProfile: applyDraftAgentProfile,
}),
commandDraftConfig,
commandDraft,
};
}, [
commandDraftConfig,
commandDraft,
composerOptions,
effectiveModelId,
effectiveThinkingOptionId,
@@ -358,7 +355,6 @@ export const __private__ = {
resolveDraftKey,
resolveEffectiveComposerModelId,
resolveEffectiveComposerThinkingOptionId,
buildDraftCommandConfig,
buildDraftComposerCommandConfig: buildDraftCommandConfig,
buildDraftCommandTarget,
buildDraftAgentControls,
};
@@ -682,7 +682,7 @@ export function WorkspaceDraftAgentTab({
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
onFocusInput={handleFocusInputCallback}
commandDraftConfig={composerState.commandDraftConfig}
commandDraft={composerState.commandDraft}
agentControls={composerAgentControls}
isCompactLayout={isCompactComposerLayout}
/>
+14 -17
View File
@@ -48,6 +48,7 @@ import {
type DraftAgentControlsProps,
} from "@/composer/agent-controls";
import { ContextWindowMeter } from "@/components/context-window-meter";
import { UsageComposerPill } from "@/usage";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
import { useFilePicker } from "@/hooks/use-file-picker";
@@ -61,7 +62,7 @@ import {
} from "./input/input";
import type { ImageAttachment, MessagePayload, TextReplacement } from "./types";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images";
import { focusWithRetries } from "@/utils/web-focus";
import {
@@ -274,7 +275,6 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null,
model: agent?.model ?? null,
provider: agent?.provider ?? null,
};
};
}
@@ -284,8 +284,6 @@ function renderContextWindowMeter(
contextWindowUsedTokens: number | null,
totalCostUsd: number | null,
showPercentage: boolean,
serverId: string,
provider: string | null,
pending: boolean,
glyphSize: number,
): ReactElement | null {
@@ -299,8 +297,6 @@ function renderContextWindowMeter(
usedTokens={contextWindowUsedTokens}
totalCostUsd={totalCostUsd}
showPercentage={showPercentage}
serverId={serverId}
provider={provider}
pending={pending}
glyphSize={glyphSize}
/>
@@ -977,8 +973,8 @@ interface ComposerProps {
autoFocusKey?: string;
/** Callback to expose a focus function to parent components (desktop only). */
onFocusInput?: (focus: () => void) => void;
/** Optional draft context for listing commands before an agent exists. */
commandDraftConfig?: DraftCommandConfig;
/** Draft context for listing commands before an agent exists. Omitted for running agents. */
commandDraft?: DraftCommandTarget;
/** Called when a message is about to be sent (any path: keyboard, dictation, queued). */
onMessageSent?: () => void;
onComposerHeightChange?: (height: number) => void;
@@ -1268,7 +1264,7 @@ function ComposerContentImpl({
autoFocus = false,
autoFocusKey,
onFocusInput,
commandDraftConfig,
commandDraft,
onMessageSent,
onComposerHeightChange,
onAttentionInputFocus,
@@ -2085,8 +2081,6 @@ function ComposerContentImpl({
contextWindowUsedTokens,
agentState.totalCostUsd,
false,
serverId,
agentState.provider,
contextWindowPending,
contextWindowMeterGlyphSize,
),
@@ -2094,15 +2088,18 @@ function ComposerContentImpl({
contextWindowMaxTokens,
contextWindowUsedTokens,
agentState.totalCostUsd,
serverId,
agentState.provider,
contextWindowPending,
contextWindowMeterGlyphSize,
],
);
const beforeVoiceContent = useMemo(
() => resolveContextWindowPlacement(contextWindowMeter, hasAgent),
[contextWindowMeter, hasAgent],
() => (
<>
{resolveContextWindowPlacement(contextWindowMeter, hasAgent)}
{hasAgent ? <UsageComposerPill serverId={serverId} agentId={agentId} /> : null}
</>
),
[agentId, contextWindowMeter, hasAgent, serverId],
);
const hasGithubAttachment = useMemo(
@@ -2349,7 +2346,7 @@ function ComposerContentImpl({
setUserInput: replaceUserInput,
serverId,
agentId,
draftConfig: commandDraftConfig,
draft: commandDraft,
canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0,
onClientSlashCommand: runClientSlashCommand,
pluginClientSlashCommands,
@@ -2358,7 +2355,7 @@ function ComposerContentImpl({
replaceUserInput,
serverId,
agentId,
commandDraftConfig,
commandDraft,
buildOutgoingAttachments,
attachments,
runClientSlashCommand,
+2 -28
View File
@@ -32,6 +32,7 @@ import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts
import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
import {
selectAgentTimelineState,
toDaemonServerInfo,
useSessionStore,
type SessionState,
} from "@/stores/session-store";
@@ -318,24 +319,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
[serverId],
);
useEffect(() => {
const serverInfo = client.getLastServerInfoMessage();
if (!serverInfo) {
return;
}
updateSessionServerInfo(serverId, {
serverId: serverInfo.serverId,
hostname: serverInfo.hostname,
version: serverInfo.version,
...(serverInfo.desktopManaged !== undefined
? { desktopManaged: serverInfo.desktopManaged }
: {}),
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
...(serverInfo.features ? { features: serverInfo.features } : {}),
});
}, [client, serverId, updateSessionServerInfo]);
useEffect(() => {
const unregister = voiceRuntime?.registerSession({
serverId,
@@ -568,16 +551,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
if (message.type !== "status") return;
const serverInfo = parseServerInfoStatusPayload(message.payload);
if (serverInfo) {
updateSessionServerInfo(serverId, {
serverId: serverInfo.serverId,
hostname: serverInfo.hostname,
version: serverInfo.version,
...(serverInfo.desktopManaged !== undefined
? { desktopManaged: serverInfo.desktopManaged }
: {}),
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
...(serverInfo.features ? { features: serverInfo.features } : {}),
});
updateSessionServerInfo(serverId, toDaemonServerInfo(serverInfo));
return;
}
});
@@ -65,7 +65,7 @@ export function BrowserDataSection() {
</Text>
</View>
<Button
variant="destructive"
variant="outline"
size="sm"
loading={isClearing}
disabled={isClearing}
@@ -131,6 +131,7 @@ export function DesktopNotificationsSection() {
</View>
{testNotificationState.status === "success" ? (
<Alert
size="sm"
variant="success"
title={t("settings.notifications.sentTitle")}
description={t("settings.notifications.sentDescription")}
@@ -139,6 +140,7 @@ export function DesktopNotificationsSection() {
) : null}
{testNotificationState.status === "error" ? (
<Alert
size="sm"
variant="error"
title={t("settings.notifications.sendFailedTitle")}
description={testNotificationState.message}
@@ -169,7 +169,7 @@ function PairDeviceBody(props: PairDeviceBodyProps) {
function OfferLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
const { t } = useTranslation();
return (
<Alert variant="error" description={message}>
<Alert size="sm" variant="error" description={message}>
<Button variant="outline" size="sm" leftIcon={RotateCw} onPress={onRetry}>
{t("pairing.device.retry")}
</Button>
@@ -197,9 +197,11 @@ function RelayConsent(props: PairDeviceBodyProps) {
accessibilityLabel={t("pairing.device.relayDocsAccessibility")}
/>
</View>
{props.enableError ? <Alert variant="error" description={props.enableError.message} /> : null}
{props.enableError ? (
<Alert size="sm" variant="error" description={props.enableError.message} />
) : null}
{!props.canConfigureRelay ? (
<Alert variant="warning" description={t("pairing.device.updateRequired")} />
<Alert size="sm" variant="warning" description={t("pairing.device.updateRequired")} />
) : null}
<View style={styles.actions}>
<Button variant="secondary" style={FLEX_ONE_STYLE} onPress={props.onClose}>
@@ -262,7 +264,7 @@ function PairingOffer(props: PairDeviceBodyProps & { offer: { url: string } }) {
{props.copied ? t("pairing.device.copied") : t("pairing.device.copy")}
</Button>
</View>
<Alert variant="warning" description={t("pairing.device.securityWarning")} />
<Alert size="sm" variant="warning" description={t("pairing.device.securityWarning")} />
</View>
);
}
@@ -0,0 +1,29 @@
import type { HostConnection } from "@/types/host-connection";
import { getDesktopHost } from "@/desktop/host";
function connectionListen(connection: HostConnection): string | null {
switch (connection.type) {
case "directTcp":
return connection.endpoint;
case "directSocket":
return `unix://${connection.path}`;
case "directPipe":
return `pipe://${connection.path}`;
case "remoteSsh":
case "relay":
return null;
}
}
// Desktop main checks that this target matches the live desktop-managed daemon.
// A plain browser has no bridge and falls back to its saved host password.
export async function readDesktopManagedLocalCredential(
connection: HostConnection,
invoke: (listen: string) => Promise<unknown> = async (listen) =>
getDesktopHost()?.invoke?.("desktop_local_credential", { listen }),
): Promise<string | undefined> {
const listen = connectionListen(connection);
if (!listen) return undefined;
const credential = await invoke(listen);
return typeof credential === "string" ? credential : undefined;
}

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