Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] cfddde086a test(cli): clean up the SSH status fixture when daemon startup fails 2026-10-07 00:26:02 +02:00
paseo-bot[bot] f2e2d58ba7 fix(cli): let daemon status reach a host over SSH
daemon status gave its connection 1.5s, including the SSH handshake for
an ssh:// host, so a host whose SSH stream takes 2.5s to open was
reported unreachable while other commands reached it. An explicit host
now gets the same connect budget as other commands; the local instance
probe keeps 1.5s.
2026-10-07 00:26:02 +02:00
Mohamed Boudra 2d85fe4e04 feat(plugins): declare display metadata in plugin manifests (#6263)
* feat(plugins): declare display metadata in plugin manifests

* style(website): format plugin page description

* fix(plugins): preserve default names and clarify registry follow-up

* test: fix stale plugin card selectors and terminal marker race
2026-10-06 23:18:24 +01:00
Mohamed Boudra 1463396ebc fix(website): plugin search ranks name matches first
Search filtered by substring and the browse page then sorted by installs,
so an exact name like "OMP" landed behind plugins that only mentioned it
in a description. Results now rank by the field matched (name, author,
description), then match quality from the shared text matcher, with the
tab's order breaking ties.
2026-10-06 23:50:27 +02:00
Mohamed Boudra d4ea77b910 fix(website): plugin install command block fits the command 2026-10-06 23:26:35 +02:00
Mohamed Boudra f715221de8 feat(website): plugins page copy describes browsing community plugins 2026-10-06 23:26:35 +02:00
Mohamed Boudra aedba5ec27 fix(website): plugin page content spans the same width as the header and footer
The page capped itself at max-w-3xl inside the site shell, so the install command, gallery, and README stopped short of the nav's right edge.
2026-10-06 23:19:16 +02:00
Mohamed Boudra bdc58fb07c feat(website): plugins hero, author and description on cards, install counts with an icon
The plugins page opens with the directory copy, plugin and author counts,
Submit a plugin and Read the docs, and search. Featured and What's new cards
show the description and the author with avatar; the trailing slot carries
installs on Featured and the added date on What's new. Every install count
on the site is the same download icon and number.
2026-10-06 23:18:20 +02:00
Mohamed Boudra 3f1a04304d website: plugin gallery peeks a third tile, viewer steps with arrows (#6261)
* website: gallery peeks a third tile, viewer steps with arrows

With three or more items the strip sizes tiles at 42% from md up so a
third tile is cut at the right edge. The viewer gains Previous and Next
buttons and Left/Right arrow keys, wrapping at the ends; moving away
from a video unmounts it, which stops playback.

* website: format the plugins directory route
2026-10-06 21:49:25 +01:00
Mohamed Boudra 50cf244e86 website e2e: expect the current Featured subtitle 2026-10-06 22:31:42 +02:00
Mohamed Boudra aaeefdc00b website: 80px between the site header and the page content 2026-10-06 22:30:40 +02:00
Mohamed Boudra c5deaa0516 Revert "website: plugins section spacing with margins, not flex gaps"
This reverts commit 72c180aaf7.
2026-10-06 22:30:39 +02:00
Mohamed Boudra 72c180aaf7 website: plugins section spacing with margins, not flex gaps
PluginSection takes its header as a prop and puts the content 32px
below it with a margin. Sections on the index are 56px apart via
space-y. The browse page h1 sits 32px above the sort tabs, matching
the index headings.
2026-10-06 22:23:50 +02:00
Mohamed Boudra 66b2567be3 Featured: subtitle and a See all plugins link 2026-10-06 22:20:10 +02:00
Mohamed Boudra cb2b569bbd Featured subtitle: hand-picked plugins that show what is possible 2026-10-06 22:15:58 +02:00
paseo-bot[bot] 2ecf3ba783 Keep an archived agent archived when a send to it fails (#6255)
* Keep an archived agent archived when a send to it fails

Sending a message to an archived agent unarchives it before loading the
session. When loading failed, for example because the agent's working
directory was removed, the agent stayed unarchived. The send now restores
the original archive timestamp when it fails, matching resume_agent.

Closes #6245

* Restore the archive only when the send cannot load the agent

Concurrent sends share one load, so a failed load leaves no session for
the restore to close. A failure after the load no longer re-archives an
agent another send may already be running.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 21:56:27 +02:00
Mohamed Boudra 4515061f88 website: submit-based plugin search on the home page, back button works on browse (#6256)
* website: submit-based plugin search on the home page, back button works on browse

* website: cover Forward in the browse search history test
2026-10-06 20:53:42 +01:00
Mohamed Boudra 3c478e5967 website: plugins page spacing, featured section, media with video, and viewer (#6254)
* website: match left and right edge padding on ranked plugin rows

The rank sat right-aligned in a fixed 16px column and the row had 8px of
padding, so a single digit started ~15.6px from the row edge while the
install count ended 8px from it. The rank now sits at the row's padding,
which is 16px on both sides, and the list's negative margin matches so
the rank still lines up with the section heading. Tabular figures keep
the tile column aligned without the fixed width.

* website: give plugins section headings room above their content

Section headings sat 16px above their cards while sections sat 56px
apart, so a heading read as closer to the section above it. A shared
PluginSection now owns the header-to-content gap (32px), used by the
index sections and by the browse results under the sort tabs.

* website: center the Build your own plugin card

The heading, description, and buttons were left-aligned in a full-width
card, leaving most of it empty. Center them and keep the description at
its readable measure.

* website: order What's new by first-listing date

newestFirst and addedAgo read submittedAt. The registry publishes the
first-listing date as publishedAt, so What's new now sorts and dates by
it. The fixture's publishedAt values become first-listing dates.

* website: plugin media with video tiles and a click-to-view viewer

The registry's published screenshots field becomes media and may hold
mp4 and webm videos. Cards and link previews use the first image, the
thumbnail route serves only image entries, and the detail gallery shows
videos as tiles alongside images. Any tile opens one viewer. An image
fits the viewport but never exceeds its natural size. A video plays with
controls and scales to the largest size that fits, small sources included.

The daemon resolver now validates only the ID and artifact it installs.

* server: drop the unreleased pluginRegistryEnabled config key

The key only existed in unreleased betas and nothing reads it. The
config schema is strict, so a config that still sets it now fails
validation and names the key.

* website: Featured section on the plugins index

The registry index gains a required featured list of plugin IDs. The
index shows those plugins above What's new, in the registry's order,
skipping IDs that are not listed, and hides the section when none are.
The index sections now share one container for their spacing.
2026-10-06 20:53:34 +01:00
paseo-bot[bot] 05637a7f05 Show a Claude subagent's auto-mode report as markdown instead of a raw SubagentHandback card (#6248)
* Show a Claude subagent's handback report as its final message

Since Claude Code 2.1.271, a subagent in auto mode delivers its report
through a SubagentHandback tool call instead of its final text. Paseo
showed that call as a generic tool card with the report as escaped JSON.
Live sidechain frames and replayed history now map the call to the
subagent's final assistant message and hide its acknowledgement.

Closes #6241

* Keep a handback report's leading whitespace and hide its acknowledgement by call id

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 20:54:16 +02:00
paseo-ai[bot] 13d21f60f1 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-06 17:40:48 +00:00
Mohamed Boudra fc3b2c4eb7 Sanitize plugin overview rendering: no raw HTML, https links only, no relative links (#6244)
* fix(plugins): restrict overview content to safe Markdown and HTTPS destinations

Registry overviews are author-controlled content read away from their repositories. Apply a shared presentation policy instead of the documentation renderer so relative links, non-HTTPS URLs, raw HTML, and author styles never become active overview content.

* fix(plugins): choose the first allowed screenshot for previews
2026-10-06 18:31:53 +01:00
paseo-bot[bot] a9fef4879a Show an archived ACP agent's history after its worktree is removed (#6228)
* Show an archived ACP agent's history after its worktree is removed

Opening an archived ACP agent loads its history by resuming the provider
session for reading. The provider process was spawned in the agent's
stored working directory, so once that directory was removed (for
example when its worktree workspace was archived) the spawn failed with
ENOENT and the chat opened empty.

A history load now starts the provider process from the home directory
when the working directory is gone, and still names the original
directory in session/load. An interactive resume keeps requiring the
directory.

Closes #5449

* Only treat a missing working directory as gone for a history load

A stat failure other than a missing path, such as a permission error,
now reaches the caller instead of moving the provider to the home
directory.

* Start the history process from home when the stored path is not a directory

A file left where an archived agent's worktree was made the history load
spawn in it and fail with ENOTDIR.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 18:54:59 +02:00
Mohamed Boudra a292b402cf Resolve local plugin directories in the CLI's working directory (#6239)
* fix(cli): resolve local plugin directories from the caller's cwd

Relative plugin sources were sent unchanged, so a daemon started elsewhere inspected the wrong directory. Resolve directory sources and expand the caller's home before sending, preserving subdirectory suffixes and managed source identifiers.

* fix(cli): preserve absolute plugin paths across host platforms

A remote daemon may use another operating system. Recognize absolute POSIX and Windows directory sources before resolving relative paths on the CLI host.
2026-10-06 17:31:57 +01:00
Mohamed Boudra 64c9f263e8 Thumbnail plugin card screenshots through Cloudflare image transformations (#6240)
* perf(website): thumbnail plugin card screenshots

* fix(website): preserve and sandbox plugin screenshot fallbacks
2026-10-06 17:30:18 +01:00
paseo-bot[bot]andDillon 48059aeae3 Show the PR panel's state, activity verbs, and thread badges in the app language (#6225)
* fix(app): show PR panel state, activity verbs, and thread badges in the app language

The PR panel's state label, the verb after each activity author, and the
Resolved and Outdated thread badges were English literals, so they stayed
English in every other language. The state and verb translations already
existed and were unused; the badges get new keys in every locale.

Closes #6214

* fix(app): translate every PR panel verb and state, and phrase them after the author

The zh-CN state and review labels still held English ("Open", "Draft",
"已 review"), and the Spanish and Portuguese verbs were adjectives that
do not read after an author's name. The translation test now checks
each verb and state on its own, so one English label fails it.

Co-authored-by: Dillon <dillonzq@outlook.com>

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Dillon <dillonzq@outlook.com>
2026-10-06 18:03:10 +02:00
Mohamed Boudra 7fd469ae19 Enable plugin registry installs by default (#6224)
* feat(plugins): enable registry installs by default

* fix(plugins): explain explicit Git source for missing registry IDs

* fix(plugins): tailor missing registry guidance to installation

* test(app): sample footer geometry in one browser frame

* fix(plugins): make registry source resolution deterministic

Directory existence must not change the meaning of a registry identifier shared by CLI, RPC, and UI installs. Classify explicit host paths in the source parser and explain recovery from registry transport and revision-selection failures.

* fix(plugins): recognize dot and Windows directory sources

* test(cli): wait for readable daemon ownership before cancellation

The cancellation test raced PID-file creation against its first write and parsed empty JSON in CI. Use the public daemon-instance reader to wait for and capture the supervisor PID before asserting cancellation cleanup.

* test(app): expect missing directory install guidance
2026-10-06 16:52:02 +01:00
paseo-bot[bot] 56d5680742 fix(highlight): keep Astro expressions colored through nested template strings (#6219)
An Astro { } expression containing a template string inside ${} closed at
the first } in that nested string, leaving the rest of the expression
uncolored. Astro and Vue now share one scanner for embedded JavaScript,
which already skips template substitutions.

Closes #6209

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 17:03:17 +02:00
Mohamed Boudra 06bde1e57a Scaffold author overviews for plugin listings (#6226)
* feat(plugins): scaffold author listing overviews

* docs(plugins): refine the author overview contract

* docs(plugins): require repository overviews for registry listings

* docs(plugins): keep listing guidance focused on authors
2026-10-06 15:58:26 +01:00
Mohamed Boudra 396a2bc0b2 fix(website): count a plugin install once per IP per hour (#6230)
Repeated install-intent resolves inflated plugin rankings. Deduplicate reports by plugin and hashed Cloudflare IP for one hour, and skip counting when the IP header is absent.
2026-10-06 15:34:15 +01:00
paseo-bot[bot] f5c55dfc55 fix(omp): reopened chats show a compaction row instead of "Unsupported history record" (#6215)
* fix(omp): reopened chats show a compaction row instead of "Unsupported history record"

OMP stores a compaction as a top-level session entry. History replay sent
it to the unknown-record fallback, so after a daemon restart the chat
showed a "Custom message" row reading "[compaction] Unsupported history
record". Replay now maps the entry to a completed compaction row.

Closes #6206

* test(omp): assert on the replayed custom-message rows directly

* test(omp): match only the unsupported-record row after restart

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 16:25:59 +02:00
paseo-bot[bot]andOuyang Xingyuan a700d7875c Stop the provider when archiving an ACP agent that never answers close (#6213)
* fix(acp): terminate the provider when close requests never return

Archive and delete wait on ACPAgentSession.close(). cancel() and
unstable_closeSession() had no timeout. The ACP SDK does not reject a
pending request when the provider never answers, so close() never
reached terminateProcess. The agent was already marked archived and the
provider process stayed alive.

Both calls now use the same 2s withTimeout as probe close. The existing
catch blocks still continue into terminateProcess.

Example:
- Input: archive an ACP session whose cancel and closeSession never settle
- Before: close() waits forever and the provider process stays alive
- After: both calls time out and terminateProcess runs

* test(acp): archive close terminates a real provider that never answers session/close

Spawn a real ACP provider process over stdio that advertises session
close and never replies to it, then close the session the way archive
does. Without the request bound, close() never returns and the provider
keeps running.

Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>

* test(acp): move the close wait and provider cleanup into helpers

Clear the race timer once close settles, and dispose the provider
process and temp directory in afterEach.

---------

Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 14:53:06 +02:00
c301d6e8aa Reinstall local speech models whose extraction was interrupted (#6210)
* fix(speech): publish models only after extraction completes

* fix(speech): reinstall local speech models whose extraction was interrupted

The daemon decided whether a local speech model was installed with its own
check in speech-runtime.ts: every required file exists and is non-empty. A
model whose extraction was stopped part-way passed that check, so the daemon
never called the downloader again and dictation aborted on the truncated
file on every start.

The models module now owns "is this model installed", and the runtime asks
it. A model whose archive is still in .downloads did not finish installing,
since the archive is removed only after the model is in place, so it is
reinstalled from that archive. Extraction goes to a directory beside the
archive and moves into place once complete.

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>

* fix(speech): remove the model archive before moving the model into place

* docs(speech): describe when the model archive is removed

---------

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 14:03:50 +02:00
Mohamed Boudra 4ede81e841 Open Usage as a modal over the current screen (#6216)
* Open Usage as a modal over the current screen

The sidebar Usage icon and summary now open Usage in the adaptive modal
(a dialog on wide layouts, a bottom sheet on compact ones) instead of
navigating to the /usage screen, so closing it leaves you where you were.
The /usage route and UsageScreen are removed.

* Reset Usage modal cards when the host changes; keep scroll test body linear
2026-10-06 12:54:09 +01:00
Mohamed Boudra 543e3712e1 fix(app): preserve chat rows across virtual layout changes (#6172)
* fix(app): preserve timeline rows across virtual layout changes

Keep recent and virtual history rows under one keyed host so entering
virtual history preserves their DOM and loaded image state. Initialize the
virtual range from the mounted viewport to avoid an empty handoff commit.

Measure committed row dimensions before accepting reading geometry,
including child layout changes flushed during resize observation. Separate
row content from position updates while retaining live revision signals.

Add a regression covering row identity through virtualization and the
position of content following a growing row. Keep the existing browser
assertions unchanged.

* test(app): verify child state survives timeline virtualization
2026-10-06 12:31:49 +01:00
paseo-bot[bot] b7c8be4081 Show the PR panel's Activity and Add to chat labels in the app language (#6208)
* fix(app): show PR panel activity and add-to-chat labels in the app language

The PR panel's Activity header, its empty state, every "Add to chat"
button, "Add all to chat", "Adding...", and the comment and thread menu
labels were written in the components, so they stayed English in every
other language. The sidebar status group rows also appended an English
" group" to their accessibility label. They now come from the
translation files, with copy for all nine supported languages.

Closes #6203

* test(app): check the new PR panel labels are translated in every language

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 12:55:44 +02:00
paseo-bot[bot] 2974d7c960 fix(desktop): keep browser_type text out of the focused Paseo composer (#6207)
browser_type committed its text with CDP Input.insertText on the tab's
debugger. Chromium commits that text into the focused element of the
whole Paseo window, so when the user was typing in an agent composer
and the browser tab did not hold focus, the agent's text landed in the
composer instead of the page.

Insert the text with the tab's own webContents.insertText, which
commits into the tab's focused element regardless of window focus.

Fixes #6182

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 11:59:59 +02:00
a7f7405c04 fix(highlight): highlight Vue (.vue) files (#6205)
* fix(highlight): add syntax highlighting support for Vue (.vue) files

* fix(highlight): use Vue-aware mixed-language parsing for .vue SFCs

* fix(highlight): make Vue interpolation boundary quote-aware and brace-balanced

The interpolation scanner matched the closing '}}' with a plain
indexOf, so expressions containing '}}' inside strings or nested
object literals ({{ { a: 1 }}}) cut the expression short. Find the
closing delimiter only at brace depth 0, skipping strings, comments,
and regexes like the existing block scanners do.

* fix(highlight): satisfy lint complexity limits in vue parser

Split nestedLanguage into templateOverlay/directiveOverlay/scriptOverlay
and drop the unused findClosingBrace helper. Behavior unchanged.

* fix(highlight): keep an empty {{}} from breaking Vue highlighting

An empty interpolation gave the expression parser a zero-length range,
which Lezer rejects, so the whole file fell back to plain text.

Co-authored-by: Davin <128177533+Davnn1@users.noreply.github.com>

* fix(highlight): color quoted Vue directive values and nested template strings

A quoted directive value went to the expression parser with its quotes,
so it parsed as one string. A template string nested inside ${} ended at
its inner backtick, so a }} inside it closed the interpolation early.

Co-authored-by: Davin <128177533+Davnn1@users.noreply.github.com>

---------

Co-authored-by: davnn <davinvin744@gmail.com>
Co-authored-by: Davnn1 <128177533+Davnn1@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 11:00:38 +02:00
paseo-bot[bot] 2dbfd1e0f8 fix(omp): reopened chats no longer show extra "Custom message" rows (#6198)
After a daemon restart, an OMP chat replays from its session file. Three
kinds of entries that the live timeline never shows were replayed as
collapsed "Custom message" rows:

- the rule-violation reminder OMP adds after a tool call matches one of
  its rules, a developer message that opens with
  <system-reminder reason="rule_violation" ...>, while only the bare
  <system-reminder> tag was hidden
- the ttsr_injection entry OMP records alongside that reminder
- the fileMention message an @-mentioned file adds, and the other OMP
  message roles the live timeline drops

Closes #6131

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 10:02:12 +02:00
paseo-bot[bot] 81a5e5bc61 Show the PR checks summary, sidebar status, and "Worked for" line in the app language (#6196)
* fix(app): translate the PR checks summary, sidebar status labels, and "Worked for" line

These were written in the components, so they stayed English in every
other language. They now come from the translation files, with copy for
all nine supported languages.

Closes #6190

* fix(app): rebuild translated labels when the app language changes

The checks summary, sidebar status groups, and turn footer now take the
translation function, so their memoized labels rebuild on a language
switch. Russian one-check group labels use singular wording.

* test(app): compare compact footer targets at sub-pixel precision

The browser reports the 44pt footer buttons as 43.99999 wide on some
runs, which fails the finger-size check on main as well.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 09:19:52 +02:00
paseo-bot[bot] 0255738ee7 Copy only the highlighted text when a selection starts below an image (#6195)
* fix(app): leave out an image when a copied selection starts below it

A drag that starts in the gap below an image in an assistant reply
anchored at the start of the image's rendered internals, ahead of the
hidden img, so the copy included the image's Markdown although only the
text after it was highlighted. A selection that starts inside an image
and ends past it now starts after the image.

Closes #6187

* fix(app): keep a failed image when a copy starts in its error text

A failed image shows its error message as text inside the image frame. A
selection that starts in that text selected part of the image, so it keeps
the image's Markdown. Only a selection that starts inside the frame before
any of its text starts after the image.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 08:03:57 +02:00
paseo-bot[bot] e3db7c3397 fix(opencode): agent goes idle after a v2 tool permission is denied (#6188)
* fix(opencode): agent goes idle after a v2 tool permission is denied

OpenCode 2.0.20 ends the execution when a tool permission is rejected and
reports the interruption with reason "shutdown", the same reason it uses for
an execution it will resume after a server restart. The turn tracker only
treated "shutdown" as final after a dismissed question, so a denied tool
permission left the agent running with nothing pending, and Stop waited on a
turn that could never complete.

Treat any denied request owned by this session as ending the turn when the
following interruption reports "shutdown". Denials of child-session requests
do not interrupt the parent execution, so they are excluded.

* test(opencode): denying a child session's permission keeps the v2 turn resumable

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 06:08:14 +02:00
paseo-bot[bot] fe10c40b9a fix(app): keep a hover card open when a pressed button disables itself (#6185)
Clicking Refresh on a usage card in the context window details closed the
card within the close grace, with the pointer still on it. Refresh disables
itself while the request is pending, which drops the click's focus to the
document, and the hover card treated any focus leaving the card as a reason
to close.

Only keyboard focus keeps a hover card open, so only losing keyboard focus
now closes it. Whether focus is keyboard focus is read when it arrives,
because the element losing focus no longer matches :focus-visible during
focusout.

Closes #6176

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 05:03:59 +02:00
fxlelouarnandClaude Opus 5.5 b6e8ca8974 fix(app): correct the French translation (#6004)
The French locale was machine-translated word by word, without context:
"Back" became "Dos" (the body part), CI "Checks" became "Chèques" (bank
cheques), "Starting" became "Départ", "Connecting" became "De liaison",
and many interpolation placeholders were glued to neighboring words
("{{label}}copié", "Daemonindisponible").

Review every client-owned French string against its English source and
call site, apply one glossary across the file, and use French
typography (’, …, non-breaking space before high punctuation).

Add regression tests for the reported labels and for placeholders glued
to French words.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 04:19:45 +02:00
paseo-bot[bot] 950e1f8f41 Keep an image's alt text and source when copying a selection from an assistant reply (#6181)
* fix(app): copy an assistant image as its Markdown, with alt text

Selecting across an image in an assistant reply copied the rendered img,
which has no alt text and, for data and workspace images, points at a
preview URL. The image frame now carries its Markdown source and alt text
the same way other Markdown elements declare their copy form.

Closes #6163

* fix(app): copy an image selected on its own as its Markdown

A selection inside the rendered image left the image's copy attributes
on the frame outside the range, so nothing was copied as Markdown.

* test(app): name the image-only selection in the copy spec

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 02:07:01 +02:00
paseo-bot[bot] 7a30305503 fix(opencode): agent goes idle after a v2 question is dismissed (#6177)
* fix(opencode): end the v2 turn when the user dismisses a question

OpenCode ends the execution when a question is dismissed but reports the
interruption with the reason "shutdown", which the turn tracker keeps open
waiting for a resume that never comes. The agent stayed running, and Stop,
reload and send were refused. A question dismissed from Paseo now lets that
interruption end the turn as canceled.

* test(opencode): cover the next message after a dismissed v2 question

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 01:06:55 +02:00
paseo-ai[bot] 3ad878b47f fix: update lockfile signatures and Nix hash [skip ci] 2026-10-05 23:02:27 +00:00
Mohamed Boudra 15d774d4a1 chore(release): cut 0.11.0-beta.5 2026-10-06 00:51:38 +02:00
Mohamed Boudra 5fd4072893 docs: changelog for 0.11.0-beta.5 2026-10-06 00:47:37 +02:00
Ruichen ZhouandMohamed Boudra 84054f8d72 fix(desktop): restore titlebar dragging after scrolling chat (#5233)
* fix(app): restore desktop screen header dragging

* fix(desktop): keep the titlebar draggable after scrolling chat

* fix(desktop): preserve global control exclusions without blocking chat headers

Keep programmatic focus containers out of the global interactive no-drag rule. Retain semantic controls everywhere, including window-owned sidebar toggles and negative-tabindex buttons. Verify both header rows and independently positioned controls with native input.

Co-authored-by: Ruichen Zhou <ruichenzhou@outlook.com>

* fix(desktop): isolate chat content from window drag regions

Electron includes scrolled interactive rectangles outside their viewport in window hit testing. Reset window regions at the chat viewport while preserving the global control exclusions for chrome and portals.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-05 23:09:19 +01:00
paseo-bot[bot] f02cc16bb4 fix(app): close hover cards when the pointer leaves a clicked row (#6167)
* fix(app): close hover cards when the pointer leaves a clicked trigger

A hover card stayed open while any focus sat inside its trigger or content,
so clicking a sidebar workspace row (which focuses the row's button) pinned
the card after the pointer moved away, until Escape or a click elsewhere.
Only keyboard-visible focus now keeps the card open, which preserves the
keyboard entry path added with the shared hover card.

* fix(app): keep a hover card open when keyboard focus arrives during its close delay

Recheck keyboard focus when the close timer fires, and move the hover card e2e steps into sidebar helpers.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 23:06:34 +02:00
paseo-bot[bot]andNurshot 2f0cb2f54b Show the Codex speed bolt in yellow while Fast or Ultrafast is selected (#6152)
* fix(app): fill the speed icon for accelerated tiers

* fix(app): highlight the Codex speed bolt when Fast or Ultrafast is selected

Codex speed moved from the Fast toggle to a Speed selector, and the
composer only colored toggle features, so the bolt stayed muted for
every speed. A select feature is now active when its selected option is
not the default, and the Codex speed takes the same yellow highlight as
the Fast toggle, on the desktop toolbar and in the compact sheet.

Closes #5939

Co-authored-by: Nurshot <85260051+Nurshot@users.noreply.github.com>

* test(app): assert the Codex speed bolt is yellow on desktop and compact

---------

Co-authored-by: Nurshot <85260051+Nurshot@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 21:10:24 +01:00
paseo-bot[bot] 4ed13fadb6 fix(app): keep list numbers when a copy crosses a code block nested in an item (#6158)
A selection starting after an item's marker demotes that item to a paragraph.
When the item holds a code block, Turndown's HTML re-parse splits the paragraph
into several siblings, and counting siblings numbered the next item too high
("4. Done." instead of "2. Done."). Each item now carries its number before
the re-parse.

Closes #6140

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 21:52:51 +02:00
Callum WongandMohamed Boudra db303be3c2 fix(nix): ship resolved daemon dependencies and working terminals (#5523)
* fix(nix): ship @getpaseo/server's main export in the desktop closure

The desktop's runtime-paths.js locates the server package root with
require.resolve("@getpaseo/server"), which resolves through the exports
map to dist/server/server/exports.js. The desktop never loads that file,
so nft doesn't trace it, and the Linux Nix desktop build fails to start
the daemon with MODULE_NOT_FOUND.

* fix(nix): retain the server resolution anchor for CLI and desktop

Run the existing Nix daemon smoke through the built CLI so package-root resolution is exercised before readiness and HTTP checks.

Co-authored-by: Callum Wong <mail@callumwong.com>

* fix(nix): ship complete terminal runtimes

Resolve runtime dependency edges at their importing modules so package links and exports survive closure tracing regardless of npm hoisting. Use the native loader's selected files and verify terminal commands and hook CLI execution through the built package.

Co-authored-by: Callum Wong <mail@callumwong.com>

* test(desktop): make packaged CLI path expectations portable

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-05 19:25:48 +01:00
Mohamed Boudra 3e2d77723e fix(usage): scope Muse and agent logins, show every failed host login (#6156)
* fix(muse): show usage only for the agent account

Usage discovery ignored session scope and exposed all remembered accounts to unrelated agents. Resolve session accounts from their own launch environment while preserving global discovery.

* test(muse): own the usage scope regression in the plugin suite

* fix(usage): scope agent reports to their own logins

Account grouping also merged fetch inputs and cache entries across agent and host queries, hiding the agent login failure behind another harness. Preserve scoped login sets, collect every host failure with source-owned harness labels, and keep refresh results within their requesting view.

* test(usage): expect harness labels in discovered accounts
2026-10-05 18:41:54 +01:00
paseo-bot[bot] 18412604dd fix(app): copy only the code when a selection touches the block beside it (#6138)
* fix(app): copy a code block without its fence when the drag starts beside it

A drag that starts or ends in the gap beside a code block anchors the
selection at the edge of the neighbouring block. Only the code is
highlighted, but the range crossed the block boundary, so the copy took
the Markdown path and wrapped the code in its fence.

A selection now crosses a code block only when it selects content
outside it. Message rows the range touches without selecting anything
are dropped, and a selection whose content all lies inside one code
block copies as that code.

* fix(app): keep a selected image when copying it beside a code block

An image selected next to a code block has no text, so the selection
counted it as empty and copied only the code. Count images as selected
content so the copy keeps the image and the fence, as it did before.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 19:21:46 +02:00
Mohamed Boudra bc6ddbfa97 fix(muse): restore earlier messages after daemon restart (#6159)
MSP view cursors request only a suffix. A fresh provider session rebuilds an empty timeline, so it must resume without a cursor to recover earlier messages. Keep gap recovery cursors in memory and omit them from durable session state.
2026-10-05 17:47:04 +01:00
Mohamed Boudra 27a95a5dec feat(website): give the plugin directory a closing section for building and submitting plugins (#6160)
The directory header now keeps only "Submit a plugin", next to the search
box. Building and submitting move into a "Build your own plugin" section at
the end of the page, with "Read the docs" and "Submit a plugin" buttons.
The docs link is labelled "Read the docs" because that is what it does.

The site footer gets top margin on every page; it sat too close to content.
2026-10-05 17:36:13 +01:00
Mohamed Boudra 9760292650 fix(providers): launch plugin CLIs on Windows and tolerate Muse startup (#6151)
* fix(providers): launch plugin CLIs on Windows and tolerate Muse startup

Plugin providers bypassed the core Windows launcher and the plugin host exposed an empty server SDK module. Share the launcher through the SDK and bind it in the host. Give Muse initialization its own deadline because native startup traverses session history before answering MSP.

* fix(providers): terminate Windows launcher process trees

A command-script launcher leaves the CLI behind cmd.exe. Expose forced process-tree termination in the server SDK, use it for Muse and SDK ACP teardown, and bound Muse closing so inherited streams cannot leave callers waiting forever.

* test(muse): keep platform setup outside the teardown assertion

* refactor(plugin): expose process capabilities without quoting helpers

* fix(providers): close process trees on uncorrelated protocol errors
2026-10-05 17:10:02 +01:00
paseo-bot[bot]andMohamed Boudra d826d8109f fix(muse): load models whose effort tiers carry descriptions (#6021)
* fix(muse): load models whose effort tiers carry descriptions

Muse 1.4.2 sends reasoningEffortVariants as described tiers
({ tier, description? }) and the complete effort list on variants, as
the bundled MSP types declare. The catalog schema had the two shapes
swapped, so any model with a described tier failed the whole model/list
parse: the model picker stayed empty and agent creation failed.

Parse both fields as declared, offer the efforts from variants, and
show Muse's tier descriptions on the matching options.

* fix(muse): leave effort descriptions out until the pickers show them

* fix(muse): keep unused catalog metadata outside validation

Described effort tiers are presentation metadata, not selectable efforts. Validating their shape blocks both discovery and session startup even though Paseo never consumes them. Normalize the complete variants list at the wire boundary and cover session readiness and selecting max.

* fix(muse): align consumed session and event wire fields

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-05 16:12:12 +01:00
paseo-bot[bot]andMohamed Boudra 62f79e88bb fix(omp): a prompt OMP 18.3 rejects after acknowledging it now fails with OMP's error (#6115)
* fix(omp): fail the turn when OMP 18.3 rejects a prompt after acknowledging it

OMP 18.3 acknowledges a prompt, then reports a rejection such as a missing
API key only as a second failed response frame for the same request id. The
JSONL RPC transport dropped any response with no pending request, so the
turn stayed running with no error.

The transport now publishes a response nothing is waiting for like any
other frame, and the OMP runtime reads a failed prompt response as the
prompt_result error later OMP releases send.

* fix(omp): drop prompt results held for an OMP process that was replaced

Request ids restart with each OMP process, so a rejection the old process
reported after its prompt timed out could fail a prompt on the new process
with the same id. Move the real-OMP rejection case into the existing OMP
real E2E suite.

* test(omp): verify late-response routing at the transport boundary

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-05 15:27:26 +01:00
paseo-bot[bot] 9da676d709 Keep the daemon running when nobody reads its output (#6117)
* fix(server): keep the daemon running when its stdout reader is gone

`paseo daemon run` in the foreground hands its stdout and stderr to the
supervisor. When the program that started it closed its end, as
`paseo daemon run | head -1` does, the supervisor's next write failed
with EPIPE and nothing handled the error. The supervisor exited 1, the
worker lost its parent and exited too, and the CLI reported "Daemon
failed to start".

The supervisor now drops output nobody reads and keeps supervising the
worker. daemon.log still records everything. Other stdout and stderr
errors surface as before.

Closes #6114

* test(server): fail on unexpected daemon.log read errors and check stderr reaches the log

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 15:57:59 +02:00
paseo-bot[bot] dfb8a1a2bc fix(omp): an OMP run with a message Paseo does not render now finishes instead of staying running (#6112)
* fix(omp): finish the turn when OMP's run contains a developer message

OMP 18.5 injects hidden developer messages into a run, such as the
rule-violation reminder after a tool call matches a built-in rule. The
agent_end frame then failed schema validation and was dropped, so the
turn stayed running until it was cancelled.

Closes #6104

* fix(omp): parse every OMP message role so agent_end is never dropped

OMP's RPC AgentMessage also defines pythonExecution, hookMessage,
branchSummary, compactionSummary and fileMention. A prompt that
@-mentions a file puts a fileMention message in agent_end, the frame
failed validation and was dropped, and the turn stayed running.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 15:15:10 +02:00
paseo-bot[bot] 8ced059b4d fix(omp): fail the turn when OMP rejects a prompt before its agent runs (#6110)
OMP reports a prompt it rejects before running the agent, such as one
with no API key for the selected model, as a prompt_result with
agentInvoked false, status error, and the error message. Paseo read only
agentInvoked and ended the turn as completed, so the agent went idle with
no reply and no error. The turn now fails with OMP's message.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 12:03:18 +02:00
paseo-bot[bot] da9fae172d fix(claude): keep a subagent's card labeled after a daemon restart (#6100)
After a restart the parent's timeline is rebuilt from Claude Code's
transcript, and the Task/Agent call went through the generic tool
mapper, which has no subagent branch, so the card replayed as a bare
"Agent" with detail type "unknown". Live, the card is built from the
task protocol's declaration instead.

Replay already reads each Task call's type and description from the
parent transcript for the subagent track. Label the replayed cards
from those same facts, through the builder the live path uses.

The shared tool-call parser is left alone: a completed sub_agent card
from it would replace the action log a foreground child's card
carries live.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 10:10:42 +02:00
paseo-bot[bot] f24bb523e8 Exit CLI commands quietly when their output is no longer read (#6097)
* fix(cli): exit quietly when stdout's reader is gone

When the program that launched the CLI closed its end of stdout, the
output write failed with EPIPE and nothing handled the error. Node
printed an unhandled 'error' event and exited 1, and the desktop app's
CLI passthrough could raise Electron's main-process error dialog.

runCli now exits with code 0 on EPIPE from stdout. Other stdout errors
still surface as before.

Closes #5982

* fix(cli): keep running when stdout's reader is gone

Exiting on EPIPE ended a command's own lifecycle: `paseo daemon run`
writes its ready line and then supervises the daemon child, so exiting
there left the child running without its parent. Drop output nobody
reads instead, and let every command finish with its own exit code.

* fix(cli): stop following when stdout's reader is gone

With EPIPE ignored, `paseo agent logs --follow` and `paseo agent attach`
kept running after the program reading their output closed it, until
interrupted. Both now wait through one helper that stops on SIGINT,
SIGTERM, or EPIPE on stdout, so a closed reader stops them at their next
line of output the same way Ctrl+C does.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 09:18:15 +02:00
paseo-bot[bot] cf2b3f4bb3 Name a linked global install as the reason daemon self-update is refused (#6095)
* fix(server): name a linked global install as the reason self-update is refused

npm 7 and later print no link flag in `npm ls --json --long`, so a global
install made with `npm install -g <dir>` or `npm link` was never detected as
linked and self-update reported that the daemon was not running from the npm
global install. Detect the link from the install directory being a symlink,
the same way npm decides it.

Closes #6086

* fix(server): fail the self-update inspection when the install path can't be read

An unreadable install path now surfaces as the update error instead of being
treated as a normal install. Tests build real install directories for both
the linked and the normal case.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 08:04:00 +02:00
a50e14b69e fix(app): keep the centered new workspace form above the keyboard on tablets (#6093)
* fix(app): keep the centered new workspace form usable with the keyboard open

The centered tablet dock translated the whole form by the full keyboard
shift and reserved the keyboard twice to keep its top below the header.
On short tablet viewports such as an unfolded foldable, that left about
20pt for the setup fields and composer, which then overlapped.

Translate the centered form only by the part of the keyboard that
reaches it, and size it to fit above the keyboard.

Fixes #6012

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

* fix(app): rest the centered new workspace form on the keyboard

The keyboard shift excludes the bottom safe area, but the keyboard also
covers it. Measuring the centered form's clearance and capacity down to
the screen edge left the form one navigation-bar height under the
keyboard, hiding Import session on Android tablets.

Measure both above the bottom safe area so the form ends at the
keyboard's top edge.

Fixes #6012

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>

---------

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-05 07:21:35 +02:00
paseo-bot[bot]androyenheart 7b43f21940 fix(app): let Remote SSH connections authenticate to a password-protected daemon (#6081)
Co-authored-by: royenheart <royenheart@outlook.com>
2026-10-05 06:02:33 +02:00
paseo-ai[bot] 2f9a786061 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-05 02:13:12 +00:00
269 changed files with 12469 additions and 2982 deletions
+19 -4
View File
@@ -36,7 +36,7 @@ jobs:
PASEO_HOME="$(mktemp -d)"
export PASEO_LISTEN=127.0.0.1:6767
WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log"
WRAPPER_LOG="$PASEO_HOME/paseo-cli.log"
cleanup() {
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
@@ -47,7 +47,7 @@ jobs:
}
trap cleanup EXIT
PASEO_WEB_UI_ENABLED=true ./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
PASEO_WEB_UI_ENABLED=true PASEO_RELAY_ENABLED=false ./result/bin/paseo daemon run >"$WRAPPER_LOG" 2>&1 &
DAEMON_PID=$!
deadline=$((SECONDS + 30))
@@ -57,7 +57,22 @@ jobs:
echo "$STATUS_JSON"
curl --fail --silent --show-error http://127.0.0.1:6767/ >"$PASEO_HOME/web-ui.html"
[[ -s "$PASEO_HOME/web-ui.html" ]]
exit 0
if ! TERMINAL_JSON="$(./result/bin/paseo terminal create --cwd "$PASEO_HOME" --name nix-smoke --json)"; then
break
fi
TERMINAL_ID="$(jq -er '.id' <<<"$TERMINAL_JSON")"
./result/bin/paseo terminal send-keys "$TERMINAL_ID" 'PATH= "$PASEO_HOOK_CLI" --version && printf "nix-terminal-%s\n" ok' Enter
deadline=$((SECONDS + 15))
while (( SECONDS < deadline )); do
if ./result/bin/paseo terminal capture "$TERMINAL_ID" --json \
| jq -e '.lines | any(. == "nix-terminal-ok")' >/dev/null; then
./result/bin/paseo terminal kill "$TERMINAL_ID"
exit 0
fi
sleep 1
done
echo "Nix terminal did not execute the command."
break
fi
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
@@ -72,7 +87,7 @@ jobs:
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
echo "::endgroup::"
echo "::group::paseo-server stdout/stderr"
echo "::group::paseo daemon run stdout/stderr"
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
echo "::endgroup::"
+39
View File
@@ -84,6 +84,45 @@
"max-nested-callbacks": ["error", { "max": 3 }]
},
"overrides": [
{
"files": [
"plugins/*-provider/server/**/*.ts",
"packages/plugin/src/server/acp-internal/**/*.ts"
],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "node:child_process",
"importNames": [
"spawn",
"exec",
"execFile",
"spawnSync",
"execSync",
"execFileSync"
],
"message": "Use the plugin server process helpers so Windows command scripts and argv are handled consistently."
},
{
"name": "child_process",
"importNames": [
"spawn",
"exec",
"execFile",
"spawnSync",
"execSync",
"execFileSync"
],
"message": "Use the plugin server process helpers so Windows command scripts and argv are handled consistently."
}
]
}
]
}
},
{
"files": ["packages/app/src/**/*.{ts,tsx}"],
"rules": {
+19 -4
View File
@@ -1,6 +1,6 @@
# Changelog
## 0.11.0-beta.4 - 2026-10-05
## 0.11.0-beta.5 - 2026-10-06
### Added
@@ -11,7 +11,7 @@
- Added Usage discovery of ChatGPT logins from Codex CLI, OpenCode, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
- Added Usage discovery of Claude logins from the Claude Code credential file, the macOS keychain, Pi, and OMP ([#5844](https://github.com/getpaseo/paseo/pull/5844))
- Added OpenCode Go to Usage ([#5465](https://github.com/getpaseo/paseo/pull/5465))
- Added the reason a Usage card is unavailable, such as an expired login with the command that refreshes it ([#5876](https://github.com/getpaseo/paseo/pull/5876))
- Added the reason a Usage card is unavailable, such as an expired login with the command that refreshes it, listing each login's error when none works ([#5876](https://github.com/getpaseo/paseo/pull/5876), [#6156](https://github.com/getpaseo/paseo/pull/6156))
- Added mid-turn steering to OMP agents ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m))
- Added Fast mode and an Auto thinking level to OMP ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@n24q02m](https://github.com/n24q02m), [@abhi-wan-kenobi](https://github.com/abhi-wan-kenobi))
- Added MCP servers to OMP schedules and Hub runs ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@omercnet](https://github.com/omercnet))
@@ -20,16 +20,19 @@
- Added the terminal key bar and Paste to terminal panes on iPad and Android tablets ([#5712](https://github.com/getpaseo/paseo/pull/5712) by [@lardissone](https://github.com/lardissone))
- Added screens and sidebar header and footer items to the plugin SDK ([#5685](https://github.com/getpaseo/paseo/pull/5685))
- Added usage sources to the plugin SDK ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5876](https://github.com/getpaseo/paseo/pull/5876))
- Added `spawnProcess()`, `execCommand()`, and `terminateProcess()` to the plugin SDK for launching CLIs, including Windows `.cmd` and `.bat` scripts ([#6151](https://github.com/getpaseo/paseo/pull/6151))
- Added `client.playAudio()` to the plugin SDK for playing audio on every client ([#5976](https://github.com/getpaseo/paseo/pull/5976))
- Added an `agent.closed` plugin lifecycle event ([#5971](https://github.com/getpaseo/paseo/pull/5971) by [@ilteoood](https://github.com/ilteoood))
- Added command and environment overrides and availability diagnostics for plugin providers ([#5707](https://github.com/getpaseo/paseo/pull/5707))
### Changed
- Enabled the plugin registry by default: `paseo plugin add owner/slug` installs a reviewed artifact even when a local directory matches; use explicit local paths (including `.`, `..`, and Windows drive or UNC paths), and use `github:owner/repository` for direct GitHub installs. Registry connection, missing-ID, and `--ref` errors include recovery steps.
- Changed the context window details to show usage for the account the agent runs under, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes ([#5465](https://github.com/getpaseo/paseo/pull/5465))
- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975))
- Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089))
- Changed Codex Fast to a Speed menu listing the Normal, Fast, and Ultrafast speeds each model offers, shown as a lightning bolt on desktop ([#5708](https://github.com/getpaseo/paseo/pull/5708))
- Changed Codex Fast to a Speed menu listing the Normal, Fast, and Ultrafast speeds each model offers, shown as a lightning bolt on desktop that turns yellow while Fast or Ultrafast is selected ([#5708](https://github.com/getpaseo/paseo/pull/5708), [#6152](https://github.com/getpaseo/paseo/pull/6152) by [@Nurshot](https://github.com/Nurshot))
- Changed Explorer tabs to work like workspace tabs, with a + menu, drag and drop, and Close for Files and Changes ([#5942](https://github.com/getpaseo/paseo/pull/5942))
- Changed Pi and OMP extension context to show as expandable tool rows instead of assistant text, and to hide Pi messages marked hidden ([#6090](https://github.com/getpaseo/paseo/pull/6090))
- Moved Import session from the sidebar footer to the New workspace screen ([#5805](https://github.com/getpaseo/paseo/pull/5805))
@@ -43,11 +46,12 @@
### Fixed
- Fixed Claude agents never showing Thought rows on Claude Opus 4.7 and later ([#4355](https://github.com/getpaseo/paseo/pull/4355) by [@ThePharmer](https://github.com/ThePharmer))
- Fixed a background Claude subagent's card showing as a bare "Task" that stayed running ([#6067](https://github.com/getpaseo/paseo/pull/6067))
- Fixed a background Claude subagent's card showing as a bare "Task" that stayed running, or as "Agent" after a daemon restart ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100))
- Fixed Claude Code terminals not showing as needing input when Claude is idle ([#6022](https://github.com/getpaseo/paseo/pull/6022))
- Fixed Claude usage calling a rate-limited login again before its retry time ([#6089](https://github.com/getpaseo/paseo/pull/6089))
- Fixed a Claude agent that entered plan mode on its own still showing and storing its previous mode ([#6074](https://github.com/getpaseo/paseo/pull/6074))
- Fixed a resumed agent returning to its creation-time mode after its mode was changed in the session ([#5140](https://github.com/getpaseo/paseo/pull/5140) by [@ThePlenkov](https://github.com/ThePlenkov))
- Fixed ACP plugin providers failing to launch a `.cmd` or `.bat` CLI on Windows ([#6151](https://github.com/getpaseo/paseo/pull/6151))
- Fixed ACP agents rejecting every following message after a turn failed with a permission request open ([#6045](https://github.com/getpaseo/paseo/pull/6045))
- Fixed the context window meter never appearing for ACP agents that report context usage ([#4848](https://github.com/getpaseo/paseo/pull/4848) by [@apple-ouyang](https://github.com/apple-ouyang))
- Fixed schedule runs and workspace metadata recording `[object Object]` when an ACP agent rejects a session setting ([#5765](https://github.com/getpaseo/paseo/pull/5765))
@@ -60,12 +64,15 @@
- Fixed results of OMP `xd://` writes disappearing from the timeline ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@kasrakhosravi](https://github.com/kasrakhosravi))
- Fixed the composer showing the stale model after an OMP model fallback ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@gray-graff](https://github.com/gray-graff))
- Fixed opening an archived OMP agent starting an `omp` process that stayed running ([#5550](https://github.com/getpaseo/paseo/pull/5550) by [@thomasvan](https://github.com/thomasvan))
- Fixed an OMP turn staying running forever when the run contains a message Paseo does not show, such as an OMP rule reminder ([#6112](https://github.com/getpaseo/paseo/pull/6112))
- Fixed a prompt OMP rejects, such as for a missing API key, ending as a completed turn with no error ([#6110](https://github.com/getpaseo/paseo/pull/6110), [#6115](https://github.com/getpaseo/paseo/pull/6115))
- Fixed Pi and OMP sessions whose model was removed failing to import or reopen ([#6061](https://github.com/getpaseo/paseo/pull/6061))
- Fixed Pi agents on Pi 0.99 starting without Paseo's MCP servers when `pi-mcp-adapter` is not installed ([#5762](https://github.com/getpaseo/paseo/pull/5762) by [@ArietidsZ](https://github.com/ArietidsZ))
- Fixed a running Pi subagent showing an empty transcript until it finished ([#5755](https://github.com/getpaseo/paseo/pull/5755))
- Fixed a grouped Tintinweb pi-subagents completion marking only its first agent completed and leaving the rest on working ([#5826](https://github.com/getpaseo/paseo/pull/5826))
- Fixed OpenCode `ask` rules for `paseo_*` tools running the tool without a prompt ([#5801](https://github.com/getpaseo/paseo/pull/5801) by [@ymarcus93](https://github.com/ymarcus93))
- Fixed the first OpenCode prompt waiting for the event stream timeout when the OpenCode server was slow to become ready ([#5873](https://github.com/getpaseo/paseo/pull/5873) by [@axsuul](https://github.com/axsuul))
- Fixed the Nix packages failing to start the daemon and to open terminals ([#5523](https://github.com/getpaseo/paseo/pull/5523) by [@wongcallum](https://github.com/wongcallum))
- Fixed OpenCode v2 agents failing to start on the Nix packages ([#5988](https://github.com/getpaseo/paseo/pull/5988))
- Fixed each Paseo skill appearing twice in Codex's skill list ([#5827](https://github.com/getpaseo/paseo/pull/5827) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed a failed Codex image generation leaving no row in the chat ([#5810](https://github.com/getpaseo/paseo/pull/5810) by [@3ae3ae](https://github.com/3ae3ae))
@@ -80,8 +87,11 @@
- Fixed the composer showing another model's label when the agent's model is missing from the loaded catalog ([#5796](https://github.com/getpaseo/paseo/pull/5796))
- Fixed the chat jumping back by several screens when scrolling up through older history on web and desktop ([#5945](https://github.com/getpaseo/paseo/pull/5945), [#5970](https://github.com/getpaseo/paseo/pull/5970))
- Fixed images in chat shifting the text below them when they finish loading ([#5970](https://github.com/getpaseo/paseo/pull/5970))
- Fixed copying all the code in a chat code block sometimes including its ``` fence lines ([#6138](https://github.com/getpaseo/paseo/pull/6138))
- Fixed copying across a code block nested in a numbered list item renumbering the next item ([#6158](https://github.com/getpaseo/paseo/pull/6158))
- Fixed relative `path:line` file links in chat opening at the first line referenced for that file ([#5653](https://github.com/getpaseo/paseo/pull/5653) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed PR comments with linked pictures showing raw HTML tags and losing the image link ([#5968](https://github.com/getpaseo/paseo/pull/5968))
- Fixed the desktop window not dragging from the workspace header after scrolling a long chat ([#5233](https://github.com/getpaseo/paseo/pull/5233) by [@Zhou-Ruichen](https://github.com/Zhou-Ruichen))
- Fixed opening an agent on a touch screen in the desktop layout raising the on-screen keyboard ([#5477](https://github.com/getpaseo/paseo/pull/5477) by [@dinhphieu](https://github.com/dinhphieu))
- Fixed Option+arrow, Cmd+arrow, and Cmd+Backspace line editing in the macOS terminal ([#3339](https://github.com/getpaseo/paseo/pull/3339) by [@gpambrozio](https://github.com/gpambrozio))
- Fixed the daemon freezing when an Add Project directory search reached a folder whose read never returns ([#6029](https://github.com/getpaseo/paseo/pull/6029), [#6041](https://github.com/getpaseo/paseo/pull/6041) by [@lardissone](https://github.com/lardissone))
@@ -89,11 +99,16 @@
- Fixed workspace script status not updating in other connected clients ([#6007](https://github.com/getpaseo/paseo/pull/6007) by [@swbiggart](https://github.com/swbiggart))
- Fixed agents and terminals inheriting the voice library path, which truncated Git Bash's PATH on Windows ([#5987](https://github.com/getpaseo/paseo/pull/5987) by [@hanh9898](https://github.com/hanh9898))
- Fixed macOS hosts showing as `UNKNOWN` when the network supplies no hostname ([#5922](https://github.com/getpaseo/paseo/pull/5922))
- Fixed `paseo daemon run` exiting with "Daemon failed to start" when the program that launched it stops reading its output ([#6117](https://github.com/getpaseo/paseo/pull/6117))
- Fixed CLI commands crashing with `EPIPE`, and the Windows desktop app showing an error dialog, when their output stops being read ([#6097](https://github.com/getpaseo/paseo/pull/6097))
- Fixed daemon self-update on a linked global install giving the wrong reason for refusing ([#6095](https://github.com/getpaseo/paseo/pull/6095))
- Fixed daemon self-update failing for an npm install under a prefix other than npm's global prefix ([#5649](https://github.com/getpaseo/paseo/pull/5649) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed opening an archived agent whose worktree was removed showing "Workspace unavailable" when the workspace had no recorded branch ([#5694](https://github.com/getpaseo/paseo/pull/5694))
- Fixed a host's version staying stale on the host page after its daemon restarts ([#5351](https://github.com/getpaseo/paseo/pull/5351) by [@bashrusakh](https://github.com/bashrusakh))
- Fixed Remote SSH hosts failing to connect to a password-protected daemon, with a new Daemon password field ([#6081](https://github.com/getpaseo/paseo/pull/6081) by [@royenheart](https://github.com/royenheart))
- Fixed removing a host during its first connection attempt not stopping the connection ([#5722](https://github.com/getpaseo/paseo/pull/5722))
- Fixed a file upload started while reconnecting failing with `Connection changed during file upload` ([#5585](https://github.com/getpaseo/paseo/pull/5585))
- Fixed the centered New workspace form collapsing and overlapping when the keyboard opens on tablets ([#6093](https://github.com/getpaseo/paseo/pull/6093) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed the Android sidebar jumping back to the top when reopened after selecting a workspace ([#6058](https://github.com/getpaseo/paseo/pull/6058))
- Fixed tapping the context ring and long-pressing a workspace tab doing nothing on native tablets ([#5713](https://github.com/getpaseo/paseo/pull/5713) by [@JichenZhang](https://github.com/JichenZhang))
- Fixed iPad chat text keeping its old width after the pane resizes ([#5716](https://github.com/getpaseo/paseo/pull/5716))
+12
View File
@@ -43,6 +43,18 @@ Run the shared browser profile fixture with:
PASEO_CAPTURE_HARNESS_GROUP=browser-profile npm run capture-harness --workspace=@getpaseo/desktop
```
On macOS, test native titlebar dragging after scrolling with:
```bash
PASEO_CAPTURE_HARNESS_GROUP=titlebar-drag npm run capture-harness --workspace=@getpaseo/desktop
```
This group needs Xcode Command Line Tools and Accessibility permission for the
terminal running it. It sends real mouse input to its test window and checks
window movement, including button clicks and a portal over the titlebar. Leave
the mouse idle during this group. It reads the app's production CSS and writes
screenshots and window bounds to the output directory.
The browser profile group runs two Electron processes in sequence. It verifies that each
renderer-side `did-attach` identity maps to the correct main-process guest, that two live
tabs share cookies and local storage through one persistent session, and that the data is
+2 -2
View File
@@ -33,8 +33,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **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. Each report renders as one card on the Usage screen, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage screen on wide layouts and the same bottom sheet on phones. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.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. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage modal over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. Usage has no route of its own. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/usage-modal.tsx`, `usage/pinned.ts`, `usage/preferences.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`).
+20 -15
View File
@@ -117,15 +117,19 @@ the bundled integration; an entry with `extends` shadows it with a custom provid
## Install from a registry
Registry installs are off by default. Set `pluginRegistryEnabled: true` in daemon config or
`PASEO_PLUGIN_REGISTRY_ENABLED=1`, then restart the daemon. While they are off, bare
`owner/repo` is GitHub shorthand and the daemon never contacts a registry.
With registry installs on, `paseo plugin install owner/slug` installs the registry's reviewed
artifact, and GitHub shorthand requires `github:`. Registry installs
`paseo plugin add owner/slug` installs the registry's reviewed artifact by default.
Browse published plugins at [paseo.sh/plugins](https://paseo.sh/plugins).
Use `git:owner/repository` or a full Git URL for a Git source. Registry installs
keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref
selection is unavailable for registry installs; install an explicit source to select your own.
Registry overviews are untrusted author content, even before installation. Render them with
`@getpaseo/protocol/plugin-overview`: no raw HTML, HTTPS-only links and images, no relative
URLs, and isolated external links. The website adapter and corpus tests live in
`packages/website/src/plugins/overview.tsx` and `overview.test.tsx`; the reusable corpus is
`packages/protocol/tests/fixtures/plugin-overview.json`. A future app overview must share
this policy. Installed-plugin descriptions in the app remain plain text.
Use `host/owner/slug` for a registry at `https://host`, or set
`PASEO_PLUGIN_REGISTRY` to change the default base (including a path prefix).
Private registry credentials live in daemon config under
@@ -138,13 +142,13 @@ owns static hosting, record shapes, pins, and advisory install counts.
## Install a Git source
GitHub repositories use `owner/repository` or `github:owner/repository`. Other hosts use a Git
URL. An existing directory still wins over source resolution.
GitHub repositories use `git:owner/repository` or `github:owner/repository`. Other hosts use a Git
URL.
```bash
paseo plugin install owner/repository
paseo plugin install git:owner/repository
paseo plugin install https://gitlab.com/group/repository.git
paseo plugin install owner/monorepo:plugins/review
paseo plugin install git:owner/monorepo:plugins/review
paseo plugin install github:owner/repository --ref main
```
@@ -155,8 +159,9 @@ and ask for approval. `ls` reports the installed commit without contacting the r
## Managed source ownership
The [public source reference](../public-docs/plugins/reference.md#plugin-sources) owns identifier
syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. Both clients send the source unchanged
through `installPluginSource`; only the daemon resolves host paths and acquires sources.
syntax and npm prerequisites; the [publishing guide](../public-docs/plugins/publishing.md) owns distribution. The CLI resolves directory sources before
calling `installPluginSource`; see the public source reference for CLI and app path semantics.
Only the daemon reads plugin files and acquires sources.
`ManagedPluginSources` owns acquisition and offline source description. Config stores the active
directory; sources.json stores managed kind and the Git acquisition remote. The remote is needed
@@ -248,7 +253,7 @@ dependency; shared types must not refer to React components, hooks, Node APIs, o
| Entry | Owns | May depend on |
| ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
| `@getpaseo/plugin/server` | Server contribution/handler contexts and lifecycle contracts | Shared and server code |
| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code |
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
| `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code |
@@ -488,9 +493,9 @@ 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`. The plugin owns account discovery and credential-store reads; the daemon owns account grouping, ordered login fallback, and the fetch cache. Scope discovery explicitly: global queries inspect machine stores; session queries inspect only the live harness's selected stores. The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `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.
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.
## Contribute sidebar items
+3 -1
View File
@@ -10,7 +10,9 @@ Keep a bundled provider in `plugins/<id>/` and register it through
import rules belong to [plugins.md](plugins.md#built-in-plugins); the
[public provider guide](../public-docs/plugins/providers.md) covers the provider contract.
The plugin owns the CLI transport, session state, catalog, and capabilities. The daemon owns
The plugin owns the CLI transport, session state, catalog, and capabilities. Launch CLI transports
and probes through the [SDK process helpers](../public-docs/plugins/providers.md#launch-the-provider-cli),
which share Windows launcher handling with core providers. The daemon owns
executable resolution and applies `agents.providers.<provider-id>.command` and `env` before
connecting. Register the provider's icon with the plugin rather than adding it to the app's
provider icon map. You do not need a core manifest entry or provider factory.
+3
View File
@@ -157,6 +157,9 @@ buildNpmPackage {
cp -a "$path" "$out/share/paseo-desktop/$path"
done < desktop-files.txt
# Shell hooks invoke the retained CLI bin directly, without a system Node.
patchShebangs --build "$out/share/paseo-desktop"
# Keep the same unpackaged monorepo layout expected by main.js.
cp package.json $out/share/paseo-desktop/
mkdir -p $out/share/paseo-desktop/packages/app
+1 -1
View File
@@ -1 +1 @@
sha256-VLcfftOTEgJ4METkulsfjJULlg+J1iJdsv72DlyiRJc=
sha256-zL3+gbScK8eMkjQUtKDX4UJTXt4BMeqMSyeWB017MA4=
+3
View File
@@ -120,6 +120,9 @@ buildNpmPackage rec {
cp -a "$path" "$out/lib/paseo/$path"
done < daemon-files.txt
# Shell hooks invoke the retained CLI bin directly, without a system Node.
patchShebangs --build "$out/lib/paseo"
# Root package.json lets node resolve the workspace layout when the
# CLI/server bin starts from $out.
cp package.json $out/lib/paseo/
+29 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.11.0-beta.4",
"version": "0.11.0-beta.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.11.0-beta.4",
"version": "0.11.0-beta.5",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36413,7 +36413,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.11.0-beta.4",
"version": "0.11.0-beta.5",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37496,12 +37496,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.11.0-beta.4",
"version": "0.11.0-beta.5",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.11.0-beta.4",
"@getpaseo/protocol": "0.11.0-beta.4",
"@getpaseo/server": "0.11.0-beta.4",
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/server": "0.11.0-beta.5",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37748,10 +37748,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.11.0-beta.4",
"version": "0.11.0-beta.5",
"dependencies": {
"@getpaseo/protocol": "0.11.0-beta.4",
"@getpaseo/relay": "0.11.0-beta.4",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/relay": "0.11.0-beta.5",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37762,7 +37762,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.11.0-beta.4",
"version": "0.11.0-beta.5",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -38006,7 +38006,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.11.0-beta.4",
"version": "0.11.0-beta.5",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38902,7 +38902,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.11.0-beta.4",
"version": "0.11.0-beta.5",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38931,14 +38931,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.11.0-beta.4",
"version": "0.11.0-beta.5",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.11.0-beta.4",
"@getpaseo/protocol": "0.11.0-beta.4",
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38948,8 +38948,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.11.0-beta.4",
"@getpaseo/protocol": "0.11.0-beta.4",
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38971,7 +38971,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.11.0-beta.4",
"version": "0.11.0-beta.5",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38998,7 +38998,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.11.0-beta.4",
"version": "0.11.0-beta.5",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39216,16 +39216,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.11.0-beta.4",
"version": "0.11.0-beta.5",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.11.0-beta.4",
"@getpaseo/highlight": "0.11.0-beta.4",
"@getpaseo/plugin": "0.11.0-beta.4",
"@getpaseo/protocol": "0.11.0-beta.4",
"@getpaseo/relay": "0.11.0-beta.4",
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/highlight": "0.11.0-beta.5",
"@getpaseo/plugin": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/relay": "0.11.0-beta.5",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -40146,7 +40146,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.11.0-beta.4",
"version": "0.11.0-beta.5",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -40174,6 +40174,7 @@
"@types/react": "^19.1.6",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.1",
"parse5": "^7.3.0",
"typescript": "^5.9.3",
"vite": "^7.3.3",
"vite-tsconfig-paths": "^5.1.4",
@@ -41753,7 +41754,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.11.0-beta.4",
"version": "0.11.0-beta.5",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.11.0-beta.4",
"version": "0.11.0-beta.5",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -120,6 +120,84 @@ async function selectAssistantElement(page: Page, selector: string): Promise<voi
});
}
/**
* Select an element from the end of the text before it. A drag that starts in the gap
* above a block anchors there, so nothing outside the element is highlighted.
*/
async function selectAssistantElementFromEndOf(
page: Page,
startText: string,
selector: string,
): Promise<void> {
await assistantMessageBlocks(page).evaluateAll(
(blocks, selected) => {
let startNode: Text | null = null;
for (const block of blocks) {
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
if (walker.currentNode.textContent?.endsWith(selected.startText)) {
startNode = walker.currentNode as Text;
}
}
}
const end = blocks.map((block) => block.querySelector(selected.selector)).find(Boolean);
if (!startNode || !end) {
throw new Error(`Could not find ${selected.startText} — ${selected.selector}`);
}
const range = document.createRange();
range.setStart(startNode, startNode.length);
range.setEnd(end, end.childNodes.length);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
},
{ startText, selector },
);
}
/** A drag over an image alone selects it inside its rendered frame. */
async function selectAssistantImage(page: Page): Promise<void> {
await assistantMessageBlocks(page)
.locator("img")
.evaluate((element) => {
const range = document.createRange();
range.selectNode(element);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
});
}
/** Press in the gap below the image and drag to the end of `text` in the block after it. */
async function dragFromBelowImageTo(page: Page, text: string): Promise<void> {
const blocks = assistantMessageBlocks(page);
const target = blocks.getByText(text);
await target.scrollIntoViewIfNeeded();
const frame = await blocks.locator('[data-paseo-markdown-tag="img"]').boundingBox();
const end = await target.evaluate((element, selectedText) => {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
while (walker.nextNode()) {
const node = walker.currentNode as Text;
const offset = node.textContent?.indexOf(selectedText) ?? -1;
if (offset >= 0) {
const range = document.createRange();
range.setStart(node, offset);
range.setEnd(node, offset + selectedText.length);
const box = range.getBoundingClientRect();
return { x: box.right - 1, y: box.top + box.height / 2, left: box.left };
}
}
throw new Error(`Could not find ${selectedText}`);
}, text);
if (!frame) {
throw new Error("Expected a rendered image");
}
await page.mouse.move(end.left + 5, frame.y + frame.height + 4);
await page.mouse.down();
await page.mouse.move(end.x, end.y, { steps: 10 });
await page.mouse.up();
}
async function selectAssistantText(page: Page, text: string): Promise<void> {
await selectAssistantTextRange(page, text, text);
}
@@ -565,6 +643,19 @@ test("copying an assistant selection preserves Markdown structure and links", as
const completeCodeClipboard = await readRichClipboard(page);
expect(completeCodeClipboard.plainText).toBe('const answer = "yes";\n return answer;');
// Touching the block before it without selecting any of its text is still a
// selection inside the code.
await selectAssistantElementFromEndOf(
page,
"not a generated link.",
'[data-paseo-markdown-language="typescript"] [data-paseo-markdown-tag="code"]',
);
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(
'const answer = "yes";\n return answer;',
);
// Crossing the block boundary expresses an intent to carry its Markdown structure.
await selectAssistantAcrossCodeLines(page, "const", "After code.");
await copySelection(page);
@@ -601,3 +692,55 @@ test("copying an assistant selection preserves Markdown structure and links", as
await agent.cleanup();
}
});
test("copying a selection across an assistant image keeps its Markdown source and alt text", async ({
context,
page,
}) => {
const image =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
const agent = await seedMockAgentWorkspace({
repoPrefix: "assistant-selection-copy-image-",
title: "Assistant selection copy image",
initialPrompt: "Render the image fixture.",
featureValues: {
mockAssistantResponse: [
"Before the image.",
"",
`![chart](${image})`,
"",
"After the image.",
].join("\n"),
},
});
try {
await allowRichClipboard(context);
await agent.client.waitForAgentUpsert(
agent.agentId,
(snapshot) => snapshot.status === "idle",
30_000,
);
await openAgentRoute(page, agent);
await expect(assistantMessageBlocks(page).locator("img")).toHaveCount(1);
await selectAssistantTextRange(page, "Before the image.", "After the image.");
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(
`Before the image.\n\n![chart](${image})\n\nAfter the image.`,
);
await selectAssistantImage(page);
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe(`![chart](${image})`);
await dragFromBelowImageTo(page, "After the");
await copySelection(page);
expect((await readRichClipboard(page)).plainText).toBe("After the");
} finally {
await agent.cleanup();
}
});
@@ -26,6 +26,10 @@ test.use({
},
});
// Rendered light-theme colors: palette yellow 400 for Fast and Ultrafast, muted foreground for Normal.
const ACTIVE_SPEED_COLOR = "rgb(251, 191, 36)";
const NORMAL_SPEED_COLOR = "rgb(113, 113, 122)";
async function openSpeedSelector(page: Page): Promise<void> {
const speed = page.getByRole("button", { name: /^(Select speed|Speed: .+)$/ });
const features = page.getByRole("button", { name: "Open agent features", exact: true });
@@ -51,6 +55,13 @@ async function selectSpeed(page: Page, label: string): Promise<void> {
else await expect(sheetTrigger).toContainText(label);
}
async function expectSpeedIconColor(page: Page, color: string): Promise<void> {
const trigger = page
.getByRole("button", { name: /^(Speed: .+|Select speed)$/ })
.filter({ visible: true });
await expect(trigger.locator("svg").first()).toHaveCSS("stroke", color);
}
async function expectSpeedChoices(page: Page, choices: string[]): Promise<void> {
for (const choice of ["Normal", "Fast", "Ultrafast"]) {
const option = page.getByText(choice, { exact: true }).last();
@@ -100,15 +111,21 @@ for (const viewport of [
await page.screenshot({ path: testInfo.outputPath("desktop-normal-trigger.png") });
}
await openSpeedSelector(page);
await selectSpeed(page, "Fast");
await expectSpeedIconColor(page, ACTIVE_SPEED_COLOR);
await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-fast-selected.png`) });
await openSpeedSelector(page);
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-speed-options.png`) });
await selectSpeed(page, "Ultrafast");
await expectSpeedIconColor(page, ACTIVE_SPEED_COLOR);
await openSpeedSelector(page);
await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]);
await page.screenshot({
path: testInfo.outputPath(`${viewport.name}-ultrafast-selected.png`),
});
await selectSpeed(page, "Normal");
await expectSpeedIconColor(page, NORMAL_SPEED_COLOR);
if (viewport.name === "desktop") {
await switchAgentModel(agent.id, "gpt-6-sol");
await openSpeedSelector(page);
@@ -268,12 +268,18 @@ for (const theme of ["light", "dark"] as const) {
await expect(card).toBeVisible();
});
await test.step("Refresh replaces the card with the source's new report", async () => {
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
await test.step("Refresh keeps the card open and replaces it with the new report", async () => {
const refreshed = gate();
usage.answerNext({ stream: [refreshed.promise, expiredLogin(onWorkLogin(claude!))] });
const card = contextWindowDetails(page);
await refreshUsageCard(card, "Claude");
await expect(card.getByRole("button", { name: "Refresh Claude" })).toBeDisabled();
// Past the card's close grace, with the pointer still on the card.
await page.waitForTimeout(500);
await expect(card).toBeVisible();
refreshed.open();
await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible();
expect(usage.refreshedReports()).toEqual([["claude:work"]]);
expect(usage.refreshedAgents()).toEqual([agent.agentId]);
});
await test.step("after a resume under another login, only that login shows", async () => {
@@ -321,7 +327,7 @@ for (const theme of ["light", "dark"] as const) {
await test.step("a host without usage reports shows only the context window", async () => {
await expectOnlyContextWindowWithoutUsage(page, usage, hoverContextWindowMeter, shot);
// One request per open that had usage; this one sends none.
expect(usage.agentRequests()).toHaveLength(5);
expect(usage.agentRequests()).toHaveLength(6);
});
});
@@ -351,7 +357,7 @@ for (const theme of ["light", "dark"] as const) {
const sheet = contextWindowDetails(page);
await refreshUsageCard(sheet, "Claude");
await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible();
expect(usage.refreshedReports()).toEqual([["claude:work"]]);
expect(usage.refreshedAgents()).toEqual([agent.agentId]);
await shot("problem");
});
@@ -365,7 +371,7 @@ for (const theme of ["light", "dark"] as const) {
await test.step("a host without usage reports shows only the context window", async () => {
await expectOnlyContextWindowWithoutUsage(page, usage, pressContextWindowMeter, shot);
expect(usage.agentRequests()).toHaveLength(2);
expect(usage.agentRequests()).toHaveLength(3);
});
});
}
@@ -1,3 +1,4 @@
import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json";
import { pluginRequirements } from "../support/helpers/plugin-fixture";
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
@@ -557,7 +558,7 @@ async function retryGitInstallAndInspectRows(
await installPlugin(page, missingSource);
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
"Plugin source is neither an existing directory nor a Git URL",
`Plugin directory does not exist: ${missingSource}`,
);
await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource);
await capturePluginInstallForm(page, testInfo, "compact-error");
@@ -655,3 +656,27 @@ async function reloadAndRemoveNpmPlugin(page: Page) {
await expect(page.getByText("Removed npm-review", { exact: true })).toBeVisible();
await expect(page.getByLabel("npm-review running")).toHaveCount(0);
}
test("renders installed-plugin descriptions as plain text", async ({ page, pluginEnvironment }) => {
const description = overviewCorpus.map(({ markdown }) => markdown).join("\n\n");
await installPluginWithUntrustedDescription(pluginEnvironment, description);
await gotoAppShell(page);
await openPluginSettings(page);
const text = page.getByText(description, { exact: true });
await expect(text).toBeVisible();
// This field must remain a text node, with no Markdown links, images, or HTML children.
expect(await text.evaluate((element) => element.childElementCount)).toBe(0);
});
async function installPluginWithUntrustedDescription(
environment: PluginEnvironment,
description: string,
) {
const directory = await createDirectoryPlugin(
environment.directory,
"plain-text-plugin",
description,
"Plain text plugin",
);
await environment.client.installPluginSource({ source: directory });
}
@@ -183,13 +183,16 @@ async function openCompactSidebar(page: Page): Promise<void> {
await expect(page.getByTestId("sidebar-footer-bottom-line")).toBeVisible();
}
const MIN_TARGET_SIZE = 44;
async function expectFingerSizedFooterTargets(page: Page): Promise<void> {
const buttons = page.getByTestId("sidebar-footer-bottom-line").getByRole("button");
await expect(buttons).toHaveCount(5);
for (const button of await buttons.all()) {
const bounds = await button.boundingBox();
expect(bounds?.width).toBeGreaterThanOrEqual(44);
expect(bounds?.height).toBeGreaterThanOrEqual(44);
// Layout can report a 44pt box as 43.99999, so compare at a hundredth of a point.
expect(bounds?.width).toBeGreaterThanOrEqual(MIN_TARGET_SIZE - 0.01);
expect(bounds?.height).toBeGreaterThanOrEqual(MIN_TARGET_SIZE - 0.01);
// Glyphs stay at the composer toolbar size (some optically smaller); only the target grows.
const glyphWidth = await button.locator("svg").first().getAttribute("width");
expect(Number(glyphWidth)).toBeLessThanOrEqual(20);
@@ -6,6 +6,11 @@ import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
expectWorkspaceHoverCardClosed,
expectWorkspaceHoverCardOpen,
expectWorkspaceHoverCardStaysOpen,
focusWorkspaceRowWithKeyboard,
movePointerAwayFromWorkspaceHoverCard,
openMobileAgentSidebar,
pinWorkspaceFromSidebar,
} from "../support/helpers/sidebar";
@@ -404,6 +409,52 @@ test.describe("Sidebar workspace list", () => {
}
});
test("workspace hover card closes when the pointer leaves a clicked row", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-click-leave-" });
try {
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(workspace.repoPath));
const row = await openWorkspaceFromSidebar(page, workspace.workspaceId);
await movePointerAwayFromWorkspaceHoverCard(page);
await expectWorkspaceHoverCardClosed(page);
await row.click();
await expect(row).toBeFocused();
await expectWorkspaceHoverCardOpen(page);
await movePointerAwayFromWorkspaceHoverCard(page);
await expectWorkspaceHoverCardClosed(page);
} finally {
await workspace.cleanup();
}
});
test("workspace hover card follows keyboard focus on a row", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" });
try {
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(workspace.repoPath));
await openWorkspaceFromSidebar(page, workspace.workspaceId);
await movePointerAwayFromWorkspaceHoverCard(page);
await expectWorkspaceHoverCardClosed(page);
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
await expectWorkspaceHoverCardOpen(page);
await movePointerAwayFromWorkspaceHoverCard(page);
await expectWorkspaceHoverCardStaysOpen(page);
await page.keyboard.press("Escape");
await expectWorkspaceHoverCardClosed(page);
await focusWorkspaceRowWithKeyboard(page, workspace.workspaceId);
await expectWorkspaceHoverCardOpen(page);
await page.keyboard.press("Tab");
await expectWorkspaceHoverCardClosed(page);
} finally {
await workspace.cleanup();
}
});
test("marks a finished workspace unread until it is opened again", async ({ page }) => {
const workspace = await seedMockAgentWorkspace({
repoPrefix: "sidebar-mark-unread-",
@@ -18,13 +18,15 @@ import {
installLoginUsage,
installCodexWindowUsage,
expectCodexReportedWindows,
openUsage,
refreshLoginUsage,
hoverUsageWindow,
} from "../support/helpers/usage-login";
import {
claudeAndCodexReports,
expectPinnedUsage,
openCompactSidebar,
openUsageFromIcon,
openUsageFromItem,
pinRow,
openUsageOptions,
closeUsageOptions,
@@ -98,7 +100,7 @@ function weeklyReport(sourceId: string, usedPct: number): UsageReportEntry {
};
}
test.describe("usage screen", () => {
test.describe("usage modal", () => {
test("opens from the sidebar on the host's reports, with no host filter for one host", async ({
page,
}) => {
@@ -134,14 +136,13 @@ test.describe("usage screen", () => {
});
await gotoAppShell(page);
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
await expect(page).toHaveURL(/\/usage$/);
await openUsageFromItem(page);
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(hostFilter(page)).toHaveCount(0);
await qaScreenshot(page, "phase7-usage-screen-one-host");
await qaScreenshot(page, "usage-modal-one-host");
await expect(group.getByText("31%")).toBeVisible();
await expect(group.getByText("Beta plan", { exact: true })).toBeVisible();
await expect(group.getByText("Unavailable", { exact: true })).toBeVisible();
@@ -176,7 +177,7 @@ test.describe("usage screen", () => {
windows: [{ id: "weekly", label: "Weekly", usedPct }],
},
});
// The sidebar summary and the screen each load reports; only a card's Refresh forces one.
// The sidebar summary and the modal each load reports; only a card's Refresh forces one.
const usage = await installUsageReportsFixture(page, {
lists: [
(request) =>
@@ -187,7 +188,7 @@ test.describe("usage screen", () => {
});
await gotoAppShell(page);
await page.locator('[data-testid="sidebar-usage"]:visible').first().click();
await openUsageFromItem(page);
const group = page.getByTestId(`usage-host-${serverId}`);
await expect(group.getByText("31%")).toBeVisible({ timeout: 10_000 });
@@ -224,7 +225,9 @@ test.describe("usage screen", () => {
});
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/usage");
await gotoAppShell(page);
await openCompactSidebar(page);
await openUsageFromIcon(page);
await usage.waitForListRequests(1);
const group = page.getByTestId(`usage-host-${serverId}`);
@@ -238,8 +241,8 @@ test.describe("usage screen", () => {
const usage = await installUsageReportsFixture(page, { usageSupported: false });
await gotoAppShell(page);
// Without reports the footer has no Usage item; its Usage icon opens the screen.
await page.locator('[data-testid="sidebar-usage-icon"]:visible').first().click();
// Without reports the footer has no Usage item; its Usage icon opens Usage.
await openUsageFromIcon(page);
await expect(
page.getByTestId(`usage-host-${serverId}`).getByText(/^Update .+ to see usage$/),
@@ -250,7 +253,7 @@ test.describe("usage screen", () => {
expect(usage.listRequests()).toHaveLength(0);
});
test("a host picked on the Usage screen is the sidebar's host too, after a reload", async ({
test("a host picked in the Usage modal is the sidebar's host too, after a reload", async ({
page,
}) => {
test.setTimeout(420_000);
@@ -273,7 +276,7 @@ test.describe("usage screen", () => {
// Nothing picked and no workspace open: the first host.
await expectPinnedUsage(page, ["31% Weekly"]);
await usageItem(page).click();
await openUsageFromItem(page);
await expect(page.getByTestId(`usage-host-${primaryServerId}`)).toBeVisible();
await hostFilter(page).click();
@@ -282,7 +285,7 @@ test.describe("usage screen", () => {
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
).toBeVisible({ timeout: 30_000 });
await expect(hostFilter(page)).toContainText("Secondary box");
await qaScreenshot(page, "usage-screen-picked-host");
await qaScreenshot(page, "usage-modal-picked-host");
await expectPinnedUsage(page, ["12% Weekly"]);
// The e2e seed resets the host list on every load, so reopening re-adds the second host.
@@ -292,6 +295,7 @@ test.describe("usage screen", () => {
port: secondary.port,
});
await expectPinnedUsage(page, ["12% Weekly"]);
await openUsageFromItem(page);
await expect(
page.getByTestId(`usage-host-${secondary.serverId}`).getByText("12%"),
).toBeVisible({ timeout: 30_000 });
@@ -320,16 +324,16 @@ test("expired login refreshes to windows with visible pin toggles", async ({ pag
});
try {
await gotoAppShell(page);
await openUsage(page);
await openUsageFromIcon(page);
await expect(
page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }),
page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }),
).toBeVisible();
await expect(page.getByText("Unavailable", { exact: true })).toBeVisible();
await qaScreenshot(page, "usage-expired-login");
await fixture.setReport(loginWindows);
await refreshLoginUsage(page);
await expect(
page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }),
page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }),
).toHaveCount(0);
await expectPinnedUsage(page, ["31% 5h", "54% wk"]);
const row = page.getByRole("checkbox", { name: /^Pin Claude Weekly, / });
@@ -366,7 +370,8 @@ test("compact usage rows always show the pin glyph", async ({ page }) => {
try {
await page.setViewportSize({ width: 390, height: 844 });
await gotoAppShell(page);
await openUsage(page);
await openCompactSidebar(page);
await openUsageFromIcon(page);
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByTestId("usage-pin-glyph-pinned")).toHaveCount(2);
await expect(card.getByTestId("usage-pin-glyph-pinned").nth(0)).toHaveCSS("opacity", "1");
@@ -413,7 +418,7 @@ for (const shape of ["seven-day-only", "Spark"] as const) {
await page.setViewportSize({ width: 1440, height: 900 });
await gotoAppShell(page);
await expect(usageItem(page)).toBeVisible();
await usageItem(page).click();
await openUsageFromItem(page);
await qaScreenshot(page, `codex-${shape}-card-and-summary`);
await expectCodexReportedWindows(page, shape);
await qaScreenshot(page, `codex-${shape}-pins`);
@@ -439,7 +444,9 @@ for (const theme of ["light", "dark"] as const) {
lists: [() => claudeAndCodexReports()],
});
await page.setViewportSize(viewport);
await page.goto("/usage");
await gotoAppShell(page);
if (size === "compact") await openCompactSidebar(page);
await openUsageFromIcon(page);
const screen = page.getByTestId(`usage-host-${getServerId()}`);
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 30_000 });
const settings = page.getByTestId("usage-options-menu");
@@ -474,3 +481,64 @@ async function captureSettingsState(
await qaScreenshot(page, name);
await testInfo.attach(name, { body: await page.screenshot(), contentType: "image/png" });
}
test("an account with all logins failing shows each harness and remedy, then only usage when one succeeds", async ({
page,
}) => {
const fixture = await installLoginUsage([
{ harness: "Codex", report: { status: "error", error: "Usage API returned 500. Try again." } },
{
harness: "OpenCode",
report: {
status: "unavailable",
problem: { kind: "rejected", status: 401, refreshedBy: "opencode" },
},
},
{
harness: "Pi",
report: { status: "unavailable", problem: { kind: "no_quota", detail: "No active plan" } },
},
{
harness: "OMP",
report: {
status: "unavailable",
problem: { kind: "rejected", status: 403, refreshedBy: "omp" },
},
},
]);
try {
await gotoAppShell(page);
await openUsageFromIcon(page);
await expectLoginErrors(page, [
"Codex: Usage API returned 500. Try again.",
"OpenCode: Login rejected (HTTP 401). Run opencode to refresh it.",
"Pi: No active plan",
"OMP: Login rejected (HTTP 403). Run omp to refresh it.",
]);
await fixture.setReport([
{
harness: "Codex",
report: { status: "error", error: "Usage API returned 500. Try again." },
},
{
harness: "OpenCode",
report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] },
},
]);
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "42%");
} finally {
await fixture.cleanup();
}
});
async function expectLoginErrors(page: Page, lines: string[]) {
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByTestId("usage-login-error")).toHaveText(lines.join("\n"));
}
async function expectLoginUsageOnly(page: Page, percentage: string) {
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByText(percentage, { exact: true })).toBeVisible();
await expect(card.getByTestId("usage-login-error")).toHaveCount(0);
}
@@ -7,7 +7,11 @@ import {
installUsageReportsFixture,
type UsageReportsFixture,
} from "../support/helpers/usage-reports";
import { refreshAllUsage, showUsageAs } from "../support/helpers/usage-sidebar-item";
import {
openUsageFromIcon,
refreshAllUsage,
showUsageAs,
} from "../support/helpers/usage-sidebar-item";
const ICON = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="currentColor"/></svg>';
@@ -98,7 +102,8 @@ test.describe("usage settings", () => {
// The shared percentages setting applies to the host section.
await expect(page.getByTestId("usage-options-menu")).toBeVisible();
const hostUsageUrl = page.url();
await page.goto("/usage");
await gotoAppShell(page);
await openUsageFromIcon(page);
await showUsageAs(page, "remaining");
await page.goto(hostUsageUrl);
await expect(card.getByText("30% left")).toBeVisible();
@@ -2,6 +2,11 @@ import path from "node:path";
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { gotoWorkspace, waitForTabBar } from "../support/helpers/launcher";
import {
connectNewWorkspaceDaemonClient,
openProjectViaDaemon,
} from "../support/helpers/new-workspace";
import { getServerId } from "../support/helpers/server-id";
import { openSettingsHostSection } from "../support/helpers/settings";
import {
@@ -14,28 +19,29 @@ import {
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
claudeAndCodexReports,
closeUsageSheet,
closeUsage,
closeUsageFromBackdrop,
closeUsageOptions,
openUsageSheetFromIcon,
expectNoUsageItem,
expectOnUsageScreen,
expectPinnedUsage,
expectSummaryInSidebar,
expectUnpinnableRows,
leaveUsageScreen,
openCompactSidebar,
openUsageScreenFromIcon,
openUsageFromIcon,
openUsageFromItem,
pinRow,
scrollUsage,
setSummaryInSidebar,
openUsageOptions,
showUsageAs,
togglePin,
usageItem,
usageSheet,
usageModal,
installTallUsageSource,
} from "../support/helpers/usage-sidebar-item";
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
import { createTempGitRepo } from "../support/helpers/workspace";
const WIDE = { width: 1440, height: 900 };
const COMPACT = { width: 390, height: 844 };
@@ -75,49 +81,96 @@ test.describe("Usage item", () => {
await expectNoUsageItem(page);
await test.step("the icon opens a sheet without leaving the current screen", async () => {
await openUsageSheetFromIcon(page);
await expect(usageSheet(page).getByText("Claude", { exact: true })).toBeVisible();
await openUsageFromIcon(page);
await expect(usageModal(page).getByText("Claude", { exact: true })).toBeVisible();
await expect(page).toHaveURL(startingUrl);
await qaScreenshot(page, "compact-usage-icon-sheet");
await setSummaryInSidebar(page, true);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
await closeUsageSheet(page);
await closeUsageFromBackdrop(page);
});
await test.step("the summary opens the same sheet", async () => {
await usageItem(page).click();
await expect(usageSheet(page).getByText("Codex", { exact: true })).toBeVisible();
await openUsageFromItem(page);
await expect(usageModal(page).getByText("Codex", { exact: true })).toBeVisible();
await expect(page).toHaveURL(startingUrl);
await qaScreenshot(page, "compact-usage-summary-sheet");
await closeUsageSheet(page);
await closeUsageFromBackdrop(page);
});
});
test("long usage reports stay in an 80% sheet and scroll to the final window", async ({
test("on desktop Usage opens as a dialog over the workspace and closes back to it", async ({
page,
}) => {
test.setTimeout(120_000);
const source = await installTallUsageSource();
const repo = await createTempGitRepo("usage-modal-");
const client = await connectNewWorkspaceDaemonClient();
try {
await page.setViewportSize(COMPACT);
const project = await openProjectViaDaemon(client, repo.path);
await page.setViewportSize(WIDE);
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await gotoAppShell(page);
await openCompactSidebar(page);
await expect(usageItem(page)).toBeInViewport();
await usageItem(page).click();
const content = page.getByTestId("sidebar-usage-sheet-content");
await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1);
await waitForSettledPosition(content);
const bounds = (await content.boundingBox())!;
expect(bounds.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
await content.getByText("Window 20", { exact: true }).scrollIntoViewIfNeeded();
await expect(content.getByText("Window 20", { exact: true })).toBeInViewport();
await content.getByText("Window 1", { exact: true }).scrollIntoViewIfNeeded();
await expect(content.getByText("Window 1", { exact: true })).toBeInViewport();
await gotoWorkspace(page, project.workspaceId);
const workspaceUrl = page.url();
await test.step("the summary opens a dialog over the workspace", async () => {
await openUsageFromItem(page);
const dialog = page.getByRole("dialog");
await expect(dialog.getByText("Claude", { exact: true })).toBeVisible();
await expect(
dialog.getByRole("button", { name: "Refresh all", exact: true }),
).toBeVisible();
await expect(page).toHaveURL(workspaceUrl);
await qaScreenshot(page, "desktop-usage-dialog");
});
await test.step("Close returns to the same workspace", async () => {
await closeUsage(page);
await expect(page).toHaveURL(workspaceUrl);
await waitForTabBar(page);
});
await test.step("the icon opens it again, and Escape closes it", async () => {
await openUsageFromIcon(page);
await page.keyboard.press("Escape");
await expect(usageModal(page)).toHaveCount(0);
await expect(page).toHaveURL(workspaceUrl);
});
} finally {
await source.cleanup();
await client.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
}
});
const layouts = {
desktop: { viewport: WIDE, showSidebar: async (_page: Page) => {} },
compact: { viewport: COMPACT, showSidebar: openCompactSidebar },
};
for (const [size, { viewport, showSidebar }] of Object.entries(layouts)) {
test(`long usage reports scroll to the final window on ${size}`, async ({ page }) => {
test.setTimeout(120_000);
const source = await installTallUsageSource();
try {
await page.setViewportSize(viewport);
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await gotoAppShell(page);
await showSidebar(page);
await expect(usageItem(page)).toBeInViewport();
await openUsageFromItem(page);
const content = usageModal(page);
await expect(content.getByText("Window 20", { exact: true })).toHaveCount(1);
await waitForSettledPosition(content);
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
await qaScreenshot(page, `${size}-usage-scroll-top`);
await scrollUsage(page, 5_000);
await expect(content.getByText("Window 20", { exact: true })).toBeInViewport();
await qaScreenshot(page, `${size}-usage-scroll-bottom`);
await scrollUsage(page, -5_000);
await expect(content.getByText("Window 20", { exact: true })).not.toBeInViewport();
} finally {
await source.cleanup();
}
});
}
test("pinned windows show in the sidebar, follow the used/remaining toggle and persist", async ({
page,
}) => {
@@ -146,8 +199,7 @@ test.describe("Usage item", () => {
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" });
await usageItem(page).click();
await expectOnUsageScreen(page);
await openUsageFromItem(page);
await qaScreenshot(page, "default-pins-card");
await expect(pinRow(screen, "Claude", "Session")).toBeChecked();
await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked();
@@ -172,17 +224,13 @@ test.describe("Usage item", () => {
await expect(usageItem(page)).not.toHaveText("Usage");
await qaScreenshot(page, "desktop-footer-pins", { kind: "footer" });
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toBeVisible();
await qaScreenshot(page, "usage-screen-header", {
kind: "element",
locator: page.getByTestId("page-title").locator(".."),
});
await qaScreenshot(page, "usage-card-refresh", {
kind: "element",
locator: screen.getByTestId("usage-report-claude:default"),
});
});
await test.step("remaining flips the Usage item and the Usage screen", async () => {
await test.step("remaining flips the Usage item and the Usage modal", async () => {
await showUsageAs(page, "remaining");
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
await expect(usageItem(page)).toHaveAccessibleName(/Claude .*69% left, Codex .*88% left/);
@@ -194,23 +242,15 @@ test.describe("Usage item", () => {
).toBeVisible();
});
await test.step("on desktop the Usage item opens the Usage screen", async () => {
await gotoAppShell(page);
await expect(page).not.toHaveURL(/\/usage$/);
await usageItem(page).click();
await expectOnUsageScreen(page);
await expect(page.getByTestId("usage-expanded")).toHaveCount(0);
});
await test.step("on a phone the Usage item opens a bottom sheet", async () => {
await closeUsage(page);
await page.setViewportSize(COMPACT);
await leaveUsageScreen(page);
await openCompactSidebar(page);
await expect(usageItem(page)).toBeInViewport();
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
await qaScreenshot(page, "compact-footer");
await usageItem(page).click();
const sheet = usageSheet(page);
await openUsageFromItem(page);
const sheet = usageModal(page);
await expect(sheet.getByText("Codex", { exact: true })).toBeInViewport();
await expect(sheet.getByText("88% left")).toBeVisible();
await expect(pinRow(sheet, "Claude", "Session")).toBeChecked();
@@ -231,25 +271,20 @@ test.describe("Usage item", () => {
await closeUsageOptions(page);
await expect(sheet.getByTestId("usage-display-used")).toHaveCount(0);
await expect(sheet).toBeVisible();
await expect(page).not.toHaveURL(/\/usage$/);
await waitForSettledPosition(sheet);
const sheetBox = (await sheet.boundingBox())!;
// The Usage sheet reserves the top 20% as its backdrop.
// The sheet leaves the screen above it as its backdrop.
expect(sheetBox.y).toBeGreaterThanOrEqual(COMPACT.height * 0.2);
expect(sheetBox.width).toBeGreaterThan(COMPACT.width * 0.8);
await qaScreenshot(page, "compact-sheet");
await qaScreenshot(page, "phase7-compact-sheet");
// Tap the backdrop above the sheet.
await page.mouse.click(COMPACT.width / 2, sheetBox.y / 2);
await expect(sheet).toHaveCount(0);
await closeUsageFromBackdrop(page);
await page.setViewportSize(WIDE);
await usageItem(page).click();
await expectOnUsageScreen(page);
});
await test.step("a reload keeps the pins and the toggle", async () => {
await page.reload();
await expectPinnedUsage(page, ["69% 5h", "88% wk"]);
await openUsageFromItem(page);
await openUsageOptions(page);
await expect(page.getByTestId("usage-display-remaining")).toHaveAttribute(
"aria-selected",
@@ -260,13 +295,15 @@ test.describe("Usage item", () => {
});
await test.step("the Settings usage section shares the pins and the toggle", async () => {
const usageUrl = page.url();
const workspaceUrl = page.url();
await closeUsage(page);
await openSettings(page);
await openSettingsHostSection(page, serverId, "usage");
const section = page.getByTestId("usage-card");
await expect(section.getByText("69% left")).toBeVisible({ timeout: 10_000 });
await expect(pinRow(section, "Codex", "Weekly")).toBeChecked();
await page.goto(usageUrl);
await page.goto(workspaceUrl);
await openUsageFromItem(page);
await expect(screen.getByText("Claude", { exact: true })).toBeVisible({ timeout: 10_000 });
});
@@ -275,6 +312,7 @@ test.describe("Usage item", () => {
await togglePin(screen, "Codex", "Weekly");
await expectNoUsageItem(page);
await page.reload();
await openUsageFromIcon(page);
await expect(screen.getByText("88% left")).toBeVisible({ timeout: 10_000 });
await expectNoUsageItem(page);
await expect(pinRow(screen, "Claude", "Session")).not.toBeChecked();
@@ -282,6 +320,7 @@ test.describe("Usage item", () => {
await expect(pinRow(screen, "Codex", "Session")).not.toBeChecked();
await expect(pinRow(screen, "Codex", "Weekly")).not.toBeChecked();
await qaScreenshot(page, "explicit-empty-pins-after-reload");
await closeUsage(page);
});
await test.step("Settings > Sidebar lists the Usage item", async () => {
@@ -325,8 +364,7 @@ test("without summary data the footer drops the Usage item and keeps the Usage i
await expectNoUsageItem(page);
await expectFooterSeparator(page, false);
await qaScreenshot(page, "desktop-footer-no-summary", { kind: "footer" });
await page.locator('[data-testid="sidebar-usage-icon"]:visible').click();
await expectOnUsageScreen(page);
await openUsageFromIcon(page);
});
test("the Usage Settings switch turns on the sidebar summary and the pins with it", async ({
@@ -340,13 +378,13 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i
const claude = screen.getByTestId("usage-report-claude:default");
await test.step("off by default: no Usage item, and rows that do not pin", async () => {
await openUsageScreenFromIcon(page);
await openUsageFromIcon(page);
await expectNoUsageItem(page);
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
await expectUnpinnableRows(claude);
await expectUnpinnableRows(screen.getByTestId("usage-report-codex:default"));
await expectSummaryInSidebar(page, false);
await qaScreenshot(page, "usage-screen-summary-off");
await qaScreenshot(page, "usage-summary-off");
});
await test.step("on: the Usage item and the pins appear", async () => {
@@ -355,7 +393,7 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i
await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked();
await togglePin(screen, "Claude", "Weekly");
await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]);
await qaScreenshot(page, "usage-screen-summary-on");
await qaScreenshot(page, "usage-summary-on");
});
await test.step("off then on again, after a reload, brings back the saved pins", async () => {
@@ -363,7 +401,7 @@ test("the Usage Settings switch turns on the sidebar summary and the pins with i
await expectNoUsageItem(page);
await expectUnpinnableRows(claude);
await page.reload();
await expectOnUsageScreen(page);
await openUsageFromIcon(page);
await expectSummaryInSidebar(page, false);
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
await expectUnpinnableRows(claude);
@@ -391,8 +429,7 @@ test("released hosts supply source logos through the client conversion", async (
await expect(summary.nth(0).locator("svg path").first()).toHaveAttribute("d", claudePath);
await expect(summary.nth(1).locator("svg path").first()).toHaveAttribute("d", codexPath);
await qaScreenshot(page, "released-host-footer", { kind: "footer" });
await usageItem(page).click();
await expectOnUsageScreen(page);
await openUsageFromItem(page);
const screen = page.getByTestId(`usage-host-${getServerId()}`);
await expect(
screen.getByTestId("usage-report-claude").locator("svg path").first(),
@@ -400,5 +437,5 @@ test("released hosts supply source logos through the client conversion", async (
await expect(
screen.getByTestId("usage-report-codex").locator("svg path").first(),
).toHaveAttribute("d", codexPath);
await qaScreenshot(page, "released-host-usage-screen");
await qaScreenshot(page, "released-host-usage-modal");
});
@@ -134,8 +134,8 @@ export interface AgentUsageScript {
stopReportingUsage(): void;
/** The agent of each agent usage request, in order. */
agentRequests(): string[];
/** Each report Refresh, by report ID. */
refreshedReports(): string[][];
/** Each scoped agent Refresh, by agent ID. */
refreshedAgents(): string[];
}
/**
@@ -162,8 +162,10 @@ export async function scriptAgentUsage(page: Page): Promise<AgentUsageScript> {
supported = false;
},
agentRequests: () => usage.listRequests().flatMap((request) => request.agentId ?? []),
refreshedReports: () =>
usage.listRequests().flatMap((request) => (request.reportIds ? [request.reportIds] : [])),
refreshedAgents: () =>
usage
.listRequests()
.flatMap((request) => (request.forceRefresh && request.agentId ? [request.agentId] : [])),
};
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { expect, type Page } from "@playwright/test";
import { getStateLabel } from "@/git/pull-request-panel/data";
import { en } from "@/i18n/resources/en";
import { openPullRequestPanel } from "./workspace-tabs";
export async function openPrPane(page: Page): Promise<void> {
@@ -14,7 +14,7 @@ export async function expectPrPaneState(
page: Page,
state: "open" | "merged" | "closed" | "draft",
): Promise<void> {
await expect(page.getByTestId("pr-pane-state")).toHaveText(getStateLabel(state), {
await expect(page.getByTestId("pr-pane-state")).toHaveText(en.workspace.git.pr.states[state], {
timeout: 15_000,
});
}
@@ -244,13 +244,16 @@ const FOOTER_GEOMETRY_TOLERANCE = 0.01;
* Settings together at the end.
*/
export async function expectFooterIconRow(page: Page): Promise<void> {
const boxes = (
await Promise.all(
FOOTER_ICON_TEST_IDS.map((testID) =>
page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
),
)
).map((box) => box!);
// Read one browser-frame snapshot: the compact drawer can translate between separate RPCs.
const boxes = await page
.locator(FOOTER_ICON_TEST_IDS.map((testID) => `[data-testid="${testID}"]:visible`).join(","))
.evaluateAll((elements) =>
elements.map((element) => {
const { x, y, width, height } = element.getBoundingClientRect();
return { testID: element.getAttribute("data-testid"), x, y, width, height };
}),
);
expect(boxes.map((box) => box.testID)).toEqual(FOOTER_ICON_TEST_IDS);
const [first] = boxes;
for (const box of boxes) {
expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2);
@@ -53,6 +53,37 @@ export async function showWorkspaceHoverCard(page: Page, workspaceId: string): P
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
}
export async function movePointerAwayFromWorkspaceHoverCard(page: Page): Promise<void> {
// Two moves, so the pointer always moves even when it is already parked here.
await page.mouse.move(1200, 200, { steps: 5 });
await page.mouse.move(1200, 400, { steps: 5 });
}
export async function focusWorkspaceRowWithKeyboard(
page: Page,
workspaceId: string,
): Promise<void> {
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
await row.focus();
await page.keyboard.press("Shift+Tab");
await page.keyboard.press("Tab");
await expect(row).toBeFocused();
}
export async function expectWorkspaceHoverCardOpen(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
}
export async function expectWorkspaceHoverCardStaysOpen(page: Page): Promise<void> {
// Longer than the hover card's close grace period.
await page.waitForTimeout(300);
await expectWorkspaceHoverCardOpen(page);
}
export async function expectWorkspaceHoverCardClosed(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-hover-card")).toHaveCount(0);
}
export async function closeWorkspaceContextMenu(page: Page, workspaceId: string): Promise<void> {
const workspaceKey = `${getServerId()}:${workspaceId}`;
await page.getByTestId(`sidebar-workspace-context-menu-${workspaceKey}-backdrop`).click();
@@ -8,12 +8,15 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { pluginRequirements } from "./plugin-fixture";
/** A real store-backed plugin on the worker's isolated daemon. */
export async function installLoginUsage(initialReport: UsageReport) {
type LoginReports = Array<{ harness: string; report: UsageReport }>;
export async function installLoginUsage(initialReport: UsageReport | LoginReports) {
const directory = await mkdtemp(path.join(tmpdir(), "usage-login-journey-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const store = path.join(directory, "report.json");
const setReport = (report: UsageReport) => writeFile(store, JSON.stringify(report));
const setReport = (report: UsageReport | LoginReports) =>
writeFile(store, JSON.stringify(report));
const cleanup = async () => {
try {
await client.removePlugin("login-journey");
@@ -35,9 +38,13 @@ export async function installLoginUsage(initialReport: UsageReport) {
import {readFile} from "node:fs/promises";
import {z} from "zod";
export default function contribute(server) {
server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({}),
discover: async () => [{key: "account", input: {}}],
fetch: async () => JSON.parse(await readFile(${JSON.stringify(store)}, "utf8")),
const read = async () => {
const value = JSON.parse(await readFile(${JSON.stringify(store)}, "utf8"));
return Array.isArray(value) ? value : [{harness: "Claude", report: value}];
};
server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({index: z.number()}),
discover: async () => (await read()).map((login, index) => ({key: "account", harness: login.harness, input: {index}})),
fetch: async ({index}) => (await read())[index].report,
});
return () => {};
}`,
@@ -51,10 +58,6 @@ export default function contribute(server) {
}
}
export async function openUsage(page: Page) {
await page.goto("/usage");
}
export async function refreshLoginUsage(page: Page) {
await page.getByRole("button", { name: "Refresh Claude", exact: true }).click();
}
@@ -8,7 +8,7 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { pluginRequirements } from "./plugin-fixture";
import { waitForSettledPosition } from "./sheet-layout";
/** Real usage-source plugin; its long report exercises the sheet's scrolling boundary. */
/** Real usage-source plugin; its long report makes the Usage modal scroll. */
export async function installTallUsageSource() {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-tall-usage-"));
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
@@ -142,13 +142,9 @@ export function usageItem(page: Page): Locator {
return visible(page, "sidebar-usage");
}
/** The compact usage sheet the Usage item opens. */
export function usageSheet(page: Page): Locator {
return visible(page, "usage-expanded");
}
export async function expectOnUsageScreen(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/usage$/);
/** The Usage modal's body: a dialog on wide layouts, a bottom sheet on compact ones. */
export function usageModal(page: Page): Locator {
return visible(page, "usage-modal-body");
}
/** Each pinned window as it reads: its percent and short label, "31% 5h". */
@@ -189,23 +185,45 @@ export async function togglePin(scope: Locator, source: string, window: string)
await expect(row).toBeChecked({ checked: !pinned });
}
/** The footer's Usage icon, which is there whether or not the Usage item is on. */
export async function openUsageScreenFromIcon(page: Page): Promise<void> {
/**
* The footer's Usage icon, which is there whether or not the Usage item is on. On a phone the
* sidebar drawer has to be open.
*/
export async function openUsageFromIcon(page: Page): Promise<void> {
await page.getByRole("button", { name: "Usage", exact: true }).click({ timeout: 30_000 });
await expectOnUsageScreen(page);
await expect(usageModal(page)).toBeVisible();
}
/** Both footer entry points open Usage over the current screen on a phone. */
export async function openUsageSheetFromIcon(page: Page): Promise<void> {
await page.getByRole("button", { name: "Usage", exact: true }).click();
await expect(usageSheet(page)).toBeVisible();
/** The footer's Usage item, its summary of pinned windows. */
export async function openUsageFromItem(page: Page): Promise<void> {
await usageItem(page).click();
await expect(usageModal(page)).toBeVisible();
}
export async function closeUsageSheet(page: Page): Promise<void> {
const sheet = usageSheet(page);
/** Scrolls the Usage modal's body with the mouse wheel, as a user does. */
export async function scrollUsage(page: Page, deltaY: number): Promise<void> {
const body = await usageModal(page).boundingBox();
const viewport = page.viewportSize();
if (!body || !viewport) throw new Error("Usage must be open before scrolling it.");
// The middle of the part of the body on screen; the body itself runs past the viewport.
const top = Math.max(body.y, 0);
const bottom = Math.min(body.y + body.height, viewport.height);
await page.mouse.move(body.x + body.width / 2, (top + bottom) / 2);
await page.mouse.wheel(0, deltaY);
}
/** The modal's own Close button. */
export async function closeUsage(page: Page): Promise<void> {
await visible(page, "usage-modal").getByRole("button", { name: "Close", exact: true }).click();
await expect(usageModal(page)).toHaveCount(0);
}
/** A tap on the backdrop above the bottom sheet. */
export async function closeUsageFromBackdrop(page: Page): Promise<void> {
const sheet = usageModal(page);
await waitForSettledPosition(sheet);
const bounds = await sheet.boundingBox();
if (!bounds) throw new Error("Usage sheet must be visible before closing it.");
const bounds = await visible(page, "usage-modal").boundingBox();
if (!bounds) throw new Error("Usage must be open before closing it.");
await page.mouse.click(bounds.x + bounds.width / 2, bounds.y / 2);
await expect(sheet).toHaveCount(0);
}
@@ -214,7 +232,7 @@ function summaryInSidebarSwitch(page: Page): Locator {
return page.getByRole("switch", { name: "Summary in sidebar", exact: true });
}
/** Turns the sidebar Usage summary on or off from the Usage screen's Settings. */
/** Turns the sidebar Usage summary on or off from the Usage modal's Settings. */
export async function setSummaryInSidebar(page: Page, on: boolean): Promise<void> {
await openUsageOptions(page);
await summaryInSidebarSwitch(page).click();
@@ -275,9 +293,3 @@ export async function refreshAllUsage(page: Page): Promise<void> {
export async function openCompactSidebar(page: Page): Promise<void> {
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
}
/** On a phone the Usage screen has a back header instead of the sidebar menu. */
export async function leaveUsageScreen(page: Page): Promise<void> {
await page.getByRole("button", { name: "Back", exact: true }).first().click();
await expect(page).not.toHaveURL(/\/usage$/);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.11.0-beta.4",
"version": "0.11.0-beta.5",
"private": true,
"main": "index.ts",
"scripts": {
+9
View File
@@ -70,6 +70,15 @@
-webkit-app-region: no-drag !important;
}
/* Electron does not clip app-region rectangles to scroll viewports.
Content-only viewports emit no window regions, including from their
controls. Visible controls here use normal DOM hit testing.
Use initial: Blink parses the explicit value none as no-drag. */
[data-window-content],
[data-window-content] * {
-webkit-app-region: initial !important;
}
/* Suppress the browser's default focus outline — it appears on mouse
clicks and looks out of place against our themed surfaces. Keep a
themed ring for keyboard users via :focus-visible. */
@@ -2,6 +2,7 @@ import type { AgentMode } from "@getpaseo/protocol/agent-types";
export const PLAN_MODE_FEATURE_ID = "plan_mode";
export const FAST_MODE_FEATURE_ID = "fast_mode";
export const SPEED_FEATURE_ID = "service_tier";
export function isPlanningAgentMode(mode: Pick<AgentMode, "id" | "colorTier">): boolean {
return mode.colorTier === "planning" || mode.id === "plan" || mode.id.endsWith("#plan");
@@ -32,7 +32,7 @@ vi.mock("react-native-unistyles", () => ({
withUnistyles: (Component: React.ComponentType) => Component,
}));
function userMessage(index: number): StreamItem {
function userMessage(index: number): Extract<StreamItem, { kind: "user_message" }> {
return {
kind: "user_message",
id: `message-${index}`,
@@ -55,6 +55,30 @@ function createRenderers(onRowRender: () => void): StreamSegmentRenderers {
};
}
function MeasuredTestRow({ item }: { item: StreamItem }) {
const [loaded, setLoaded] = React.useState(false);
const load = React.useCallback(() => setLoaded(true), []);
const height = item.kind === "user_message" && item.text === "grown" ? 120 : 24;
const measure = React.useCallback(
(node: HTMLDivElement | null) => {
if (!node?.parentElement) return;
Object.defineProperty(node.parentElement, "offsetHeight", {
configurable: true,
value: height,
});
},
[height],
);
return (
<div ref={measure}>
<button type="button" onClick={load}>
{loaded ? "Loaded" : "Load"}
</button>
{item.id}
</div>
);
}
describe("createWebStreamStrategy", () => {
let root: Root | null = null;
let container: HTMLDivElement | null = null;
@@ -155,6 +179,75 @@ describe("createWebStreamStrategy", () => {
expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length);
});
it("retains a row's DOM and local state when recent history becomes virtualized", () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const item = userMessage(0);
const renderRow = (rowItem: StreamItem) => <MeasuredTestRow item={rowItem} />;
const input: StreamRenderInput = {
agentId: "agent",
segments: { historyVirtualized: [], historyMounted: [item], liveHead: [] },
boundary: { hasVirtualizedHistory: false, hasMountedHistory: true, hasLiveHead: false },
renderers: {
renderHistoryVirtualizedRow: renderRow,
renderHistoryMountedRow: renderRow,
renderLiveHeadRow: renderRow,
renderLiveAuxiliary: () => null,
},
listEmptyComponent: null,
viewportRef: React.createRef<StreamViewportHandle>(),
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: () => {},
onNearHistoryStart: () => true,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
act(() => root?.render(strategy.render(input)));
const row = container.querySelector('[data-history-row-id="message-0"]')!;
act(() => row.querySelector("button")!.click());
expect(row.querySelector("button")!.textContent).toBe("Loaded");
const viewport = container.querySelector('[data-testid="agent-chat-scroll"]')!;
Object.defineProperty(viewport, "clientHeight", { configurable: true, value: 500 });
act(() =>
root?.render(
strategy.render({
...input,
segments: { historyVirtualized: [item], historyMounted: [userMessage(1)], liveHead: [] },
boundary: { ...input.boundary, hasVirtualizedHistory: true },
}),
),
);
expect(container.querySelector('[data-history-row-id="message-0"]')).toBe(row);
expect(row.isConnected).toBe(true);
expect(row.querySelector("button")!.textContent).toBe("Loaded");
act(() =>
root?.render(
strategy.render({
...input,
segments: {
historyVirtualized: [{ ...item, text: "grown" }, userMessage(1)],
historyMounted: [userMessage(2)],
liveHead: [],
},
boundary: { ...input.boundary, hasVirtualizedHistory: true },
}),
),
);
expect(
(container.querySelector('[data-history-row-id="message-1"]') as HTMLElement).style.transform,
).toBe("translateY(120px)");
expect(row.querySelector("button")!.textContent).toBe("Loaded");
});
it("keeps the timeline width stable with an overlay scrollbar", () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: false });
container = document.createElement("div");
+93 -39
View File
@@ -20,6 +20,7 @@ import { withUnistyles } from "react-native-unistyles";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useStableEvent } from "@/hooks/use-stable-event";
import type { StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
@@ -76,6 +77,8 @@ const historyStartSlotStyle: CSSProperties = {
flexShrink: 0,
};
const streamRowsHostStyle: CSSProperties = { position: "relative", width: "100%" };
const streamRowStyle: CSSProperties = {
display: "flex",
flexDirection: "column",
@@ -268,6 +271,23 @@ function isScrollContainerOverscrolledPastBottom(
return getScrollContainerDistanceFromBottom(scrollContainer) < -BOTTOM_OVERSCROLL_TOLERANCE_PX;
}
// Position updates do not change a row's content. Keep renderer work separate
// from virtual measurements while retaining the same component across lanes.
const StreamRowContent = React.memo(function StreamRowContent({
item,
index,
items,
render,
}: {
revision?: StreamRenderInput["liveHeadRowRevision"];
item: StreamItem;
index: number;
items: StreamItem[];
render: StreamRenderInput["renderers"]["renderHistoryVirtualizedRow"];
}) {
return render(item, index, items);
});
function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) {
const {
segments: inputSegments,
@@ -425,6 +445,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const rowVirtualizer = useVirtualizer({
count: segments.historyVirtualized.length,
enabled: shouldUseVirtualizer,
initialRect: {
width: scrollContainerRef.current?.clientWidth ?? 0,
height: scrollContainerRef.current?.clientHeight ?? 0,
},
getScrollElement: () => scrollContainerRef.current,
getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index,
estimateSize: (index: number) => {
@@ -536,25 +560,31 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scheduler.schedule();
});
// ResizeObserver runs before paint. Batch measurements and commit React's new
// positions here so a resized image cannot overlap rows for an extra frame.
// Use resizeItem exclusively; measureElement would install a second observer.
const measureVirtualizedRows = useStableEvent(() => {
let resized = false;
for (const element of contentRef.current?.querySelectorAll<HTMLElement>(
"[data-history-row-id][data-index]",
) ?? []) {
const index = Number(element.dataset.index);
const height = measureVirtualElement(element, undefined, rowVirtualizer);
if (rowVirtualizer.measurementsCache[index]?.size === height) continue;
rowVirtualizer.resizeItem(index, height);
resized = true;
}
return resized;
});
// Measure the committed DOM rather than queued observer entries: flushSync can
// also commit an image's pending intrinsic dimensions, invalidating those entries.
const virtualRowObserver = useMemo(
() =>
new ResizeObserver((entries) => {
new ResizeObserver(() => {
if (!isActiveRef.current) return;
flushSync(() => {
for (const entry of entries) {
const element = entry.target as HTMLElement;
if (!element.isConnected) continue;
rowVirtualizer.resizeItem(
Number(element.dataset.index),
measureVirtualElement(element, entry, rowVirtualizer),
);
}
measureVirtualizedRows();
});
}),
[rowVirtualizer],
[measureVirtualizedRows],
);
const measureVirtualizedRowElement = useCallback(
(node: HTMLDivElement | null) => {
@@ -731,8 +761,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
// Apply row positions and their scroll correction in the same paint, including
// the ordinary-to-virtualized handoff and measurements of newly mounted rows.
useLayoutEffect(() => {
// Commit positions for every resized row before accepting reading geometry.
// Child layout can change during this commit, ahead of ResizeObserver delivery.
if (!isActive || measureVirtualizedRows()) return;
reconcileReadingPosition();
}, [contentWidth, isActive, segments, virtualRows, reconcileReadingPosition]);
}, [
contentWidth,
isActive,
segments,
virtualRows,
measureVirtualizedRows,
reconcileReadingPosition,
]);
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
cancelPendingStickToBottom();
@@ -915,6 +955,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
evaluateHistoryStart();
}
const observer = new ResizeObserver(() => {
flushSync(() => {
measureVirtualizedRows();
});
const nextGeometry = getObservedViewportGeometry(scrollContainer);
if (pendingResumeGeometryCheckRef.current) {
pendingResumeGeometryCheckRef.current = false;
@@ -953,6 +996,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
scheduleHistoryStartPrependSettle,
scheduleStickToBottom,
updateScrollMetrics,
measureVirtualizedRows,
]);
useLayoutEffect(() => {
@@ -1171,13 +1215,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
}),
[],
);
const virtualRowsContainerStyle = useMemo((): CSSProperties => {
return {
position: "relative",
width: "100%",
const virtualRowsContainerStyle = useMemo(
(): CSSProperties => ({
height: virtualTotalSize,
};
}, [virtualTotalSize]);
flexShrink: 0,
}),
[virtualTotalSize],
);
const renderVirtualRowStyle = useCallback(
(start: number): CSSProperties => ({
position: "absolute",
@@ -1198,12 +1242,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
data-message-id={getStreamItemMessageId(item)}
style={streamRowStyle}
>
{renderHistoryMountedRow(item, index, segments.historyMounted)}
<StreamRowContent
item={item}
index={index}
items={segments.historyMounted}
render={renderHistoryMountedRow}
/>
</div>
));
}, [renderHistoryMountedRow, segments.historyMounted]);
const liveHeadRows = useMemo(() => {
void liveHeadRowRevision;
return segments.liveHead.map((item, index) => (
<div
key={item.id}
@@ -1211,7 +1259,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
data-message-id={getStreamItemMessageId(item)}
style={streamRowStyle}
>
{renderLiveHeadRow(item, index, segments.liveHead)}
<StreamRowContent
item={item}
index={index}
items={segments.liveHead}
revision={liveHeadRowRevision}
render={renderLiveHeadRow}
/>
</div>
));
}, [liveHeadRowRevision, renderLiveHeadRow, segments.liveHead]);
@@ -1241,7 +1295,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
!liveAuxiliary;
return (
<div style={viewportStyle}>
<div style={viewportStyle} data-window-content>
<div
ref={handleScrollContainerRef}
data-testid="agent-chat-scroll"
@@ -1251,13 +1305,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
>
<div ref={handleContentRef} style={contentContainerStyle}>
{historyStartSlot}
{shouldUseVirtualizer ? (
<div style={virtualRowsContainerStyle}>
{virtualRows.map((virtualRow) => {
<div style={streamRowsHostStyle}>
<div style={virtualRowsContainerStyle} />
{[
...virtualRows.map((virtualRow) => {
const item = segments.historyVirtualized[virtualRow.index];
if (!item) {
return null;
}
if (!item) return null;
return (
<div
key={virtualRow.key}
@@ -1267,17 +1320,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
ref={measureVirtualizedRowElement}
style={renderVirtualRowStyle(virtualRow.start)}
>
{renderHistoryVirtualizedRow(
item,
virtualRow.index,
segments.historyVirtualized,
)}
<StreamRowContent
item={item}
index={virtualRow.index}
items={segments.historyVirtualized}
render={renderHistoryVirtualizedRow}
/>
</div>
);
})}
</div>
) : null}
{mountedRows}
}),
...mountedRows,
]}
</div>
{liveAuxiliary}
{shouldRenderEmpty ? listEmptyComponent : null}
</div>
-2
View File
@@ -863,7 +863,6 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
pathname === "/new" ||
pathname === "/sessions" ||
pathname === "/schedules" ||
pathname === "/usage" ||
routeHasKnownHost);
return <AppContainer chromeEnabled={shouldShowAppChrome}>{children}</AppContainer>;
@@ -896,7 +895,6 @@ 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]" />
-10
View File
@@ -1,10 +0,0 @@
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { UsageScreen } from "@/usage";
export default function UsageRoute() {
return (
<HostRouteBootstrapBoundary>
<UsageScreen />
</HostRouteBootstrapBoundary>
);
}
@@ -143,6 +143,52 @@ describe("assistant selection copy ranges", () => {
expect(copiedMarkdown(selectRange(blocks[0]!, 6, blocks[1]!, 6))).toBe("paragraph.\n\nSecond");
});
// Chrome anchors a drag that starts in the gap below an image at the start of the
// image's rendered internals: the background-image layer, ahead of the hidden `img`.
// Only the text after it is highlighted.
it("leaves out an image when the selection starts in the gap below it", () => {
const transcript = mountTranscript([
{
messageId: "message-1",
blocks: [
"Before the image.",
'<div data-paseo-markdown-tag="img" data-paseo-markdown-src="https://example.test/chart.png" data-paseo-markdown-alt="chart"><div><div style="background-image: url(blob:https://example.test/preview)"></div><img src="blob:https://example.test/preview" alt=""></div></div>',
"After the image.",
],
},
]);
const frame = fixtureElement(transcript, '[data-paseo-markdown-tag="img"]');
const range = document.createRange();
range.setStart(frame.firstElementChild!.firstElementChild!, 0);
range.setEnd(textNode(fixtureElement(transcript, '[data-paseo-markdown-tag="p"]', 2)), 9);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
expect(copiedMarkdown(selection)).toBe("After the");
});
it("keeps a failed image when the selection starts in its error text", () => {
const transcript = mountTranscript([
{
messageId: "message-1",
blocks: [
'<div data-paseo-markdown-tag="img" data-paseo-markdown-src="https://example.test/chart.png" data-paseo-markdown-alt="chart"><div>Image failed to load</div></div>',
"After the image.",
],
},
]);
const frame = fixtureElement(transcript, '[data-paseo-markdown-tag="img"]');
const selection = selectRange(
frame.firstElementChild!,
6,
fixtureElement(transcript, '[data-paseo-markdown-tag="p"]', 1),
9,
);
expect(copiedMarkdown(selection)).toBe("![chart](https://example.test/chart.png)\n\nAfter the");
});
it("does not replace the browser clipboard for a selection reaching a second message", () => {
const transcript = mountTranscript([
{ messageId: "message-1", blocks: ["First paragraph.", "Second paragraph."] },
@@ -320,6 +366,32 @@ describe("assistant selection copy ranges", () => {
).toBe("6. Second bullet text");
});
it("keeps the next number when a drag crosses a code block nested in an item", () => {
const message = mountFixture();
const list = fixtureElement(message, '[data-paseo-markdown-tag="ul"]');
list.outerHTML = [
'<div data-paseo-markdown-tag="ol" data-paseo-markdown-list-start="1">',
'<div data-paseo-markdown-tag="li">',
'<div data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">1.</div>',
"<div><div><span>Install it:</span></div>",
'<div data-paseo-markdown-tag="pre" data-paseo-markdown-language="sh">',
'<div data-paseo-markdown-tag="code"><span>brew install foo</span></div>',
'<button data-paseo-markdown-ignore="true">Copy</button>',
"</div></div>",
"</div>",
'<div data-paseo-markdown-tag="li">',
'<div data-paseo-markdown-ignore="true" data-paseo-markdown-list-marker="true">2.</div>',
"<div><div><span>Done.</span></div></div>",
"</div>",
"</div>",
].join("");
const content = copyAcross(message, "Install it:", "Done.");
expect(content?.plainText).toBe("Install it:\n\n```sh\nbrew install foo\n```\n\n2. Done.");
expect(content?.html).toContain("<div>2. Done.</div>");
});
it("retains nested markers when a drag includes the complete outer item", () => {
const message = mountFixture();
const firstItem = fixtureElement(message, '[data-paseo-markdown-tag="li"]');
@@ -385,6 +457,20 @@ describe("assistant selection copy ranges", () => {
expect(content?.html).not.toContain("<pre>");
});
it("copies an image as its Markdown when the selection stays inside the rendered image", () => {
const message = mountFixture();
const frame = document.createElement("div");
frame.setAttribute("data-paseo-markdown-tag", "img");
frame.setAttribute("data-paseo-markdown-src", "https://example.test/chart.png");
frame.setAttribute("data-paseo-markdown-alt", "chart");
frame.innerHTML = '<div><div><img src="blob:https://example.test/preview" alt=""></div></div>';
message.prepend(frame);
expect(copiedMarkdown(selectNodeContents(fixtureElement(frame, "img").parentElement!))).toBe(
"![chart](https://example.test/chart.png)",
);
});
it("copies code without a fence when every character is selected inside the block", () => {
const message = mountFixture();
const blockCode = fixtureElement(
@@ -405,10 +491,19 @@ describe("assistant selection copy ranges", () => {
* Turndown, so those cases need a fixture shaped like the real thing.
*/
function highlightedFixture(language: string | null): string {
return [
'<div data-testid="assistant-message">',
'<div data-paseo-markdown-tag="p"><span>Before the block.</span></div>',
highlightedCodeBlock(language),
'<div data-paseo-markdown-tag="p"><span>After the block.</span></div>',
"</div>",
].join("");
}
function highlightedCodeBlock(language: string | null): string {
const languageAttribute =
language === null ? "" : ` data-paseo-markdown-language="${escapeAttribute(language)}"`;
return [
'<div data-testid="assistant-message">',
`<div data-paseo-markdown-tag="pre"${languageAttribute}>`,
'<span data-paseo-markdown-tag="code">',
"<span>const</span><span> answer</span><span> = 1;</span>",
@@ -419,8 +514,6 @@ function highlightedFixture(language: string | null): string {
"</span>",
'<div data-paseo-markdown-ignore="true"><span>Copy</span></div>',
"</div>",
'<div data-paseo-markdown-tag="p"><span>After the block.</span></div>',
"</div>",
].join("");
}
@@ -598,6 +691,65 @@ describe("assistant selection copy inside highlighted code", () => {
);
});
// A drag that starts or ends in the gap beside a block anchors the selection at the
// edge of the neighbouring block. Nothing outside the code is highlighted or selected.
it("copies code without a fence when the selection only touches the end of the block before it", () => {
const message = mountHighlighted();
const before = tokenText(message, "Before the block.");
const content = copyBetween(
[before, before.length],
[tokenText(message, " doThing();"), 14],
);
expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();");
});
it("copies code without a fence when the selection only touches the start of the block after it", () => {
const message = mountHighlighted();
const content = copyBetween(
[tokenText(message, "const"), 0],
[tokenText(message, "After the block."), 0],
);
expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();");
});
it("copies code without a fence when the selection only touches the row before the block", () => {
const transcript = mountTranscript([{ messageId: "message-1", blocks: ["Run this:", "code"] }]);
const codeRow = transcript.querySelectorAll('[data-testid="assistant-message"]')[1]!;
codeRow.innerHTML = highlightedCodeBlock("typescript");
const before = tokenText(transcript, "Run this:");
const content = copyBetween(
[before, before.length],
[tokenText(transcript, " doThing();"), 14],
);
expect(content?.plainText).toBe("const answer = 1;\n if (answer) {\n doThing();");
});
it("keeps an image selected beside the code in the copy", () => {
const message = mountHighlighted();
const image = document.createElement("div");
image.setAttribute("data-paseo-markdown-tag", "p");
// The rendered `img` shows a preview URL with no alt text; the frame carries the Markdown.
image.innerHTML =
'<div data-paseo-markdown-tag="img" data-paseo-markdown-src="https://example.test/chart.png" data-paseo-markdown-alt="chart"><img src="blob:https://example.test/preview" alt=""></div>';
message.querySelector('[data-paseo-markdown-tag="pre"]')!.before(image);
const before = tokenText(message, "Before the block.");
const content = copyBetween(
[before, before.length],
[tokenText(message, " doThing();"), 14],
);
expect(content?.plainText).toBe(
"![chart](https://example.test/chart.png)\n\n```typescript\nconst answer = 1;\n if (answer) {\n doThing();\n```",
);
});
it("retains the fence when a complete code block is selected across its boundary", () => {
const message = mountHighlighted();
@@ -7,10 +7,12 @@ import {
} from "@/utils/rich-clipboard";
import {
MARKDOWN_COPY_ALIGN_ATTRIBUTE,
MARKDOWN_COPY_ALT_ATTRIBUTE,
MARKDOWN_COPY_IGNORE_ATTRIBUTE,
MARKDOWN_COPY_LANGUAGE_ATTRIBUTE,
MARKDOWN_COPY_LIST_MARKER_ATTRIBUTE,
MARKDOWN_COPY_LIST_START_ATTRIBUTE,
MARKDOWN_COPY_SRC_ATTRIBUTE,
MARKDOWN_COPY_TAG_ATTRIBUTE,
MARKDOWN_COPY_UNWRAP_ATTRIBUTE,
TRAILING_CODE_LINE_BREAKS,
@@ -22,6 +24,7 @@ const CHAT_SCROLL_SELECTOR = '[data-testid="agent-chat-scroll"]';
const messageRowSelector = (messageId: string) => `[data-message-id="${CSS.escape(messageId)}"]`;
const CODE_BLOCK_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="pre"]`;
const CODE_REGION_SELECTOR = `${CODE_BLOCK_SELECTOR}, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="code"]`;
const IMAGE_FRAME_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="img"]`;
const turndown = new TurndownService({
bulletListMarker: "-",
@@ -53,9 +56,7 @@ turndown.addRule("compactListItem", {
if (parent?.nodeName !== "OL") {
return `${options.bulletListMarker} ${item}\n`;
}
const start = Number(parent.getAttribute("start") ?? 1);
const index = Array.from(parent.children).indexOf(node);
return `${start + index}. ${item}\n`;
return `${node.getAttribute("value")}. ${item}\n`;
},
});
@@ -66,9 +67,9 @@ export function createAssistantSelectionClipboardContent(
return null;
}
const range = selection.getRangeAt(0);
const parts = selectedMessageParts(range);
if (!parts) {
const range = startAfterImageFrame(selection.getRangeAt(0));
const parts = selectedMessageParts(range)?.filter((part) => selectsContent(part.range));
if (!parts?.length) {
return null;
}
@@ -93,6 +94,33 @@ export function createAssistantSelectionClipboardContent(
return { ...content, html: flattenClipboardListMarkup(content.html) };
}
/**
* A drag that starts in the gap below an image anchors at the start of the image's
* rendered internals, ahead of the hidden `img`, while only the text after it is
* highlighted. Pressing on the image itself opens it instead of starting a selection,
* so a selection that starts inside an image, before any of its text, and ends past it
* starts after the image. A failed image shows its error as text, and a selection
* starting in that text keeps the image.
*/
function startAfterImageFrame(range: Range): Range {
const start =
range.startContainer instanceof Element
? range.startContainer
: range.startContainer.parentElement;
const frame = start?.closest(IMAGE_FRAME_SELECTOR);
if (!frame || frame.contains(range.endContainer)) {
return range;
}
const insideFrame = range.cloneRange();
insideFrame.setEnd(frame, frame.childNodes.length);
if (insideFrame.toString()) {
return range;
}
const afterFrame = range.cloneRange();
afterFrame.setStartAfter(frame);
return afterFrame;
}
/**
* Partly-selected code copies as the code itself, never through Turndown.
*
@@ -103,10 +131,12 @@ export function createAssistantSelectionClipboardContent(
*
* A selection contained inside code always copies as code, even when it contains every
* character. A selection that crosses the code boundary stays on the Markdown path so
* a complete block retains its fence.
* a complete block retains its fence. Crossing means selecting content outside the code:
* a drag that starts or ends in the gap beside a block anchors at the edge of the
* neighbouring block, which selects nothing there.
*/
function createPartialCodeContent(range: Range, message: Element): MarkdownClipboardContent | null {
const region = closestCodeRegion(range.commonAncestorContainer, message);
const region = selectedCodeRegion(range, message);
if (!region) {
return null;
}
@@ -130,6 +160,18 @@ function createPartialCodeContent(range: Range, message: Element): MarkdownClipb
});
}
function selectedCodeRegion(range: Range, message: Element): Element | null {
const edgeRegions = [range.startContainer, range.endContainer].map((node) =>
closestCodeRegion(node, message),
);
return (
edgeRegions.find(
// Measure against the whole block, so its own hover Copy button is not outside it.
(edge) => edge && selectsNothingOutside(range, edge.closest(CODE_BLOCK_SELECTOR) ?? edge),
) ?? null
);
}
function closestCodeRegion(node: Node, message: Element): Element | null {
const element = node instanceof Element ? node : node.parentElement;
const region = element?.closest(CODE_REGION_SELECTOR) ?? null;
@@ -281,7 +323,8 @@ function shouldPreserveSemanticElement(range: Range, element: Element): boolean
return false;
}
const tag = element.getAttribute(MARKDOWN_COPY_TAG_ATTRIBUTE);
if (tag === "p" || isTableStructure(tag)) {
// An image has no part to select, so touching it selects all of it.
if (tag === "p" || tag === "img" || isTableStructure(tag)) {
return true;
}
const isSelectableSemantic = tag !== null && tag !== "li" && tag !== "ol" && tag !== "ul";
@@ -384,6 +427,31 @@ function hasSelectedAllContents(range: Range, element: Element, includeIgnored =
return true;
}
function selectsContent(range: Range): boolean {
return hasMarkdownContent(range.cloneContents(), true);
}
function selectsNothingOutside(range: Range, element: Element): boolean {
const contents = document.createRange();
contents.selectNodeContents(element);
if (range.compareBoundaryPoints(Range.START_TO_START, contents) < 0) {
const before = range.cloneRange();
before.setEnd(contents.startContainer, contents.startOffset);
if (selectsContent(before)) {
return false;
}
}
if (range.compareBoundaryPoints(Range.END_TO_END, contents) > 0) {
const after = range.cloneRange();
after.setStart(contents.endContainer, contents.endOffset);
if (selectsContent(after)) {
return false;
}
}
return true;
}
function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean): boolean {
if (!includeIgnored) {
for (const ignored of fragment.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) {
@@ -393,8 +461,9 @@ function hasMarkdownContent(fragment: DocumentFragment, includeIgnored: boolean)
if (fragment.textContent) {
return true;
}
const visibleVoidSelector = ["br", "hr"]
const visibleVoidSelector = ["br", "hr", "img"]
.map((tag) => `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="${tag}"]`)
.concat("img")
.join(",");
return Boolean(fragment.querySelector(visibleVoidSelector));
}
@@ -488,14 +557,19 @@ function restoreMarkdownElements(container: HTMLElement): void {
continue;
}
const semanticElement = document.createElement(tagName);
if (tagName !== "br") {
if (tagName !== "br" && tagName !== "img") {
semanticElement.append(...element.childNodes);
}
if (tagName === "img") {
semanticElement.setAttribute("src", element.getAttribute(MARKDOWN_COPY_SRC_ATTRIBUTE) ?? "");
semanticElement.setAttribute("alt", element.getAttribute(MARKDOWN_COPY_ALT_ATTRIBUTE) ?? "");
}
if (tagName === "ol") {
const start = element.getAttribute(MARKDOWN_COPY_LIST_START_ATTRIBUTE);
if (start) {
semanticElement.setAttribute("start", start);
}
numberOrderedListItems(semanticElement, Number(start ?? 1));
}
if (tagName === "pre") {
const language = element.getAttribute(MARKDOWN_COPY_LANGUAGE_ATTRIBUTE);
@@ -528,6 +602,20 @@ function restoreMarkdownElements(container: HTMLElement): void {
}
}
/**
* Each item carries its number before Turndown re-parses the HTML. Until then every
* child of the list is one item, including a partly selected item demoted to a `p`.
* The parser splits a `p` that holds a code block into several siblings, so counting
* siblings afterwards numbers the next item too high.
*/
function numberOrderedListItems(list: Element, start: number): void {
Array.from(list.children).forEach((child, index) => {
if (child.tagName === "LI") {
child.setAttribute("value", String(start + index));
}
});
}
function unwrapIncompleteTables(container: HTMLElement): void {
for (const table of container.querySelectorAll("table")) {
if (hasUsableTableColumnContract(table)) {
@@ -5,6 +5,8 @@ export const MARKDOWN_COPY_UNWRAP_ATTRIBUTE = "data-paseo-markdown-unwrap";
export const MARKDOWN_COPY_LIST_START_ATTRIBUTE = "data-paseo-markdown-list-start";
export const MARKDOWN_COPY_LANGUAGE_ATTRIBUTE = "data-paseo-markdown-language";
export const MARKDOWN_COPY_ALIGN_ATTRIBUTE = "data-paseo-markdown-align";
export const MARKDOWN_COPY_SRC_ATTRIBUTE = "data-paseo-markdown-src";
export const MARKDOWN_COPY_ALT_ATTRIBUTE = "data-paseo-markdown-alt";
/**
* Trailing line breaks, with any indentation that followed the last one.
@@ -63,6 +65,18 @@ export function markdownCopyCodeBlockDataSet(language: string | null | undefined
} as const;
}
/**
* An image copies as the Markdown it came from. The rendered `img` points at a preview
* URL for data and workspace images, and carries no alt text of its own.
*/
export function markdownCopyImageDataSet(source: string, alt: string | undefined) {
return {
paseoMarkdownTag: "img",
paseoMarkdownSrc: source,
...(alt ? { paseoMarkdownAlt: alt } : {}),
} as const;
}
export function markdownCopyTableCellDataSet(tag: "td" | "th", style: unknown) {
const alignment =
typeof style === "string"
@@ -1,13 +1,25 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import {
Pressable,
Text,
View,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Terminal } from "lucide-react-native";
import { Eye, EyeOff, Terminal } from "lucide-react-native";
import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport";
import type { HostProfile } from "@/types/host-connection";
import { useHostMutations, useHosts } from "@/runtime/host-runtime";
import { Button } from "@/components/ui/button";
import { Field, FormTextInput } from "@/components/ui/form-field";
import {
CONTROL_HEIGHTS,
createControlGeometry,
resolveControlInteractionStyles,
} from "@/components/ui/control-geometry";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { useIsCompactFormFactor } from "@/constants/layout";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
@@ -15,18 +27,64 @@ import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const ThemedTerminal = withUnistyles(Terminal);
const ThemedEye = withUnistyles(Eye, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedEyeOff = withUnistyles(EyeOff, (theme) => ({ color: theme.colors.foregroundMuted }));
const styles = StyleSheet.create((theme) => ({
helper: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
actions: {
flexDirection: "row",
gap: theme.spacing[3],
marginTop: theme.spacing[2],
},
}));
const styles = StyleSheet.create((theme) => {
const geometry = createControlGeometry(theme);
return {
helper: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
passwordRow: {
flexDirection: "row",
alignItems: "center",
alignSelf: "stretch",
width: "100%",
gap: theme.spacing[2],
},
passwordInputWrap: {
flex: 1,
minWidth: 0,
},
// Boxed adornment next to the field: same height and radius as the field
// chrome of the active form size, and the shared interaction phases.
iconButtonSm: {
minHeight: CONTROL_HEIGHTS.compact,
width: CONTROL_HEIGHTS.compact,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
},
iconButtonMd: {
minHeight: CONTROL_HEIGHTS.field,
width: CONTROL_HEIGHTS.field,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
},
controlRest: {
...geometry.controlRest,
},
controlHover: {
...geometry.controlHover,
},
controlActive: {
...geometry.controlActive,
},
controlDisabled: {
...geometry.controlDisabled,
},
actions: {
flexDirection: "row",
gap: theme.spacing[3],
marginTop: theme.spacing[2],
},
};
});
export interface AddRemoteSshHostModalProps {
visible: boolean;
@@ -51,14 +109,20 @@ export function AddRemoteSshHostModal({
const isCompact = useIsCompactFormFactor();
const { probeAndUpsertRemoteSshConnection } = useHostMutations();
const targetRef = useRef("");
const passwordRef = useRef("");
const inputRef = useRef<EditingTextInputHandle>(null);
const passwordInputRef = useRef<EditingTextInputHandle>(null);
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const header = useMemo<SheetHeader>(() => ({ title: t("pairing.remoteSsh.title") }), [t]);
const clear = useCallback(() => {
targetRef.current = "";
passwordRef.current = "";
inputRef.current?.replaceText("");
passwordInputRef.current?.replaceText("");
setIsPasswordVisible(false);
setErrorMessage("");
}, []);
@@ -94,7 +158,10 @@ export function AddRemoteSshHostModal({
try {
setIsSaving(true);
setErrorMessage("");
result = await probeAndUpsertRemoteSshConnection(target);
result = await probeAndUpsertRemoteSshConnection({
...target,
password: passwordRef.current === "" ? undefined : passwordRef.current,
});
} catch (error) {
const message =
error instanceof DaemonConnectionTestError
@@ -116,6 +183,34 @@ export function AddRemoteSshHostModal({
const handleTargetChange = useCallback((value: string) => {
targetRef.current = value;
}, []);
const handlePasswordChange = useCallback((value: string) => {
passwordRef.current = value;
}, []);
const handleTogglePasswordVisibility = useCallback(() => {
setIsPasswordVisible((currentlyVisible) => !currentlyVisible);
}, []);
const iconButtonStyle = useCallback(
(
state: PressableStateCallbackType & { hovered?: boolean; focused?: boolean },
): StyleProp<ViewStyle> => [
isCompact ? styles.iconButtonMd : styles.iconButtonSm,
resolveControlInteractionStyles(
{
controlRest: styles.controlRest,
controlHover: styles.controlHover,
controlActive: styles.controlActive,
controlDisabled: styles.controlDisabled,
},
{
hovered: state.hovered,
pressed: state.pressed,
focused: state.focused,
disabled: isSaving,
},
),
],
[isCompact, isSaving],
);
const handleSubmit = useCallback(() => void handleSave(), [handleSave]);
return (
@@ -146,6 +241,41 @@ export function AddRemoteSshHostModal({
onSubmitEditing={handleSubmit}
/>
</Field>
<Field label={t("pairing.remoteSsh.fields.password")} testID="remote-ssh-password">
<View style={styles.passwordRow}>
<View style={styles.passwordInputWrap}>
<FormTextInput
ref={passwordInputRef}
size={isCompact ? "md" : "sm"}
testID="remote-ssh-password-input"
accessibilityLabel={t("pairing.remoteSsh.fields.password")}
initialValue=""
onChangeText={handlePasswordChange}
placeholder={t("pairing.remoteSsh.fields.optional")}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry={!isPasswordVisible}
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={handleSubmit}
/>
</View>
<Pressable
style={iconButtonStyle}
onPress={handleTogglePasswordVisibility}
disabled={isSaving}
accessibilityRole="button"
accessibilityLabel={
isPasswordVisible
? t("pairing.remoteSsh.passwordVisibility.hide")
: t("pairing.remoteSsh.passwordVisibility.show")
}
testID="remote-ssh-password-visibility-toggle"
>
{isPasswordVisible ? <ThemedEyeOff size={18} /> : <ThemedEye size={18} />}
</Pressable>
</View>
</Field>
<View style={styles.actions}>
<Button
style={FLEX_ONE_STYLE}
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import { getTurnDurationLabel } from "./assistant-turn-footer-label";
describe("getTurnDurationLabel", () => {
it("says how long the turn worked", () => {
expect(getTurnDurationLabel(14_000, i18n.t)).toBe("Worked for 14s");
});
it("reads in the active app language", async () => {
await i18n.changeLanguage("fr");
try {
expect(getTurnDurationLabel(14_000, i18n.t)).toBe("A travaillé pendant 14s");
} finally {
await i18n.changeLanguage("en");
}
});
});
@@ -0,0 +1,6 @@
import type { TFunction } from "i18next";
import { formatDuration } from "@/utils/time";
export function getTurnDurationLabel(durationMs: number, t: TFunction): string {
return t("message.turnFooter.workedFor", { duration: formatDuration(durationMs) });
}
+9 -7
View File
@@ -74,6 +74,7 @@ import { useRevealedText } from "@/hooks/use-revealed-text";
import { colorMarkdownLinkChildren } from "@/components/markdown/link-children";
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
import { getTurnDurationLabel } from "./assistant-turn-footer-label";
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
import { setAssistantMarkdownBlockHeight } from "@/utils/assistant-message-height-estimate";
@@ -108,6 +109,7 @@ import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assist
import { useRetainedPanelActive } from "@/components/retained-panel";
import {
markdownCopyDataSet,
markdownCopyImageDataSet,
markdownCopyOrderedListDataSet,
markdownCopyTableCellDataSet,
type MarkdownCopyInlineTag,
@@ -628,6 +630,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
durationMs,
onFork,
}: AssistantTurnFooterProps) {
const { t } = useTranslation();
const [hovered, setHovered] = useState(false);
const [pressedReveal, setPressedReveal] = useState(false);
const revealTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -643,10 +646,8 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
const durationLabel = useMemo(
() =>
durationMs !== undefined && durationMs !== null
? `Worked for ${formatDuration(durationMs)}`
: "",
[durationMs],
durationMs !== undefined && durationMs !== null ? getTurnDurationLabel(durationMs, t) : "",
[durationMs, t],
);
const timestampLabel = useMemo(
() => (completedAt ? formatMessageTimestamp(completedAt) : ""),
@@ -884,10 +885,11 @@ function AssistantMarkdownImage({
],
[containerStyle, imageSizeStyle],
);
const copyDataSet = useMemo(() => markdownCopyImageDataSet(source, alt), [source, alt]);
if (image.status === "failed") {
return (
<View style={stateFrameStyle}>
<View style={stateFrameStyle} dataSet={copyDataSet}>
<Text style={assistantMessageStylesheet.imageErrorText}>{image.message}</Text>
</View>
);
@@ -895,14 +897,14 @@ function AssistantMarkdownImage({
if (!binding) {
return (
<View style={stateFrameStyle}>
<View style={stateFrameStyle} dataSet={copyDataSet}>
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
</View>
);
}
return (
<View style={frameStyle}>
<View style={frameStyle} dataSet={copyDataSet}>
<Pressable
accessibilityLabel={t("composer.attachments.openImage")}
accessibilityRole="button"
@@ -1,8 +1,9 @@
import { ActivityIndicator, View, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model";
import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import {
@@ -170,6 +171,7 @@ function ProjectStatusBadge({
statusBucket: SidebarStateBucket;
backdrop: SidebarSurfaceBackdrop;
}) {
const { t } = useTranslation();
// Running skips the shell. The ring is wider than the 12pt shell and carries its own knockout,
// so nesting it inside would clip it against the very thing that was meant to separate it from
// the icon. It anchors to the same corner instead, growing around the centre the dot had.
@@ -177,7 +179,7 @@ function ProjectStatusBadge({
return (
<View
role="status"
accessibilityLabel={STATUS_BUCKET_LABELS[statusBucket]}
accessibilityLabel={getStatusBucketLabel(statusBucket, t)}
style={styles.statusRingAnchor}
testID="project-status-badge"
>
@@ -188,7 +190,7 @@ function ProjectStatusBadge({
return (
<View
role="status"
accessibilityLabel={STATUS_BUCKET_LABELS[statusBucket]}
accessibilityLabel={getStatusBucketLabel(statusBucket, t)}
style={[styles.statusBadge, getStatusBadgeBackdropStyle(backdrop)]}
testID="project-status-badge"
>
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import React, { createContext, useContext, useEffect, useMemo, type ReactNode } from "react";
import {
useSidebarWorkspacesList,
@@ -54,6 +55,7 @@ export function SidebarModelProvider({
active?: boolean;
children: ReactNode;
}) {
const { t } = useTranslation();
const list = useSidebarWorkspacesList({ enabled: active });
const groupMode = useSidebarViewStore((state) => state.groupMode);
const labelFilter = useSidebarViewStore((state) => state.labelFilter);
@@ -150,6 +152,7 @@ export function SidebarModelProvider({
pinnedCollapsed,
collapsedProjectKeys,
collapsedWorkspaceGroupKeys,
t,
}),
[
collapsedProjectKeys,
@@ -161,6 +164,7 @@ export function SidebarModelProvider({
pinnedKeys,
pinnedWorkspaceOrder,
filteredWorkspaceEntriesByKey,
t,
],
);
const projection = useMemo(() => buildSidebarProjection(projectionInput), [projectionInput]);
@@ -6,7 +6,7 @@ import type { SidebarSection } from "@/sidebar-nav/model";
/**
* The popover a sidebar item opens: anchored to the item on wide layouts, a bottom sheet on
* compact ones. Plugin items and the built-in Usage item open it the same way:
* compact ones. Plugin items open it this way:
* `SidebarPopoverRoot` around the item, `useSidebarPopoverAnchor` on the element to anchor to,
* and `SidebarPopoverSurface` for the body.
*/
@@ -1,3 +1,4 @@
import { i18n } from "@/i18n/i18next";
import { describe, expect, it } from "vitest";
import type {
SidebarProjectEntry,
@@ -85,6 +86,7 @@ function projectionInput(options?: {
pinnedCollapsed: options?.pinnedCollapsed ?? false,
collapsedProjectKeys: new Set<string>(),
collapsedWorkspaceGroupKeys: new Set<string>(),
t: i18n.t,
};
}
@@ -1,3 +1,4 @@
import type { TFunction } from "i18next";
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
import {
splitPinnedSidebarGroups,
@@ -45,6 +46,7 @@ export interface SidebarProjectionInput {
pinnedCollapsed: boolean;
collapsedProjectKeys: ReadonlySet<string>;
collapsedWorkspaceGroupKeys: ReadonlySet<string>;
t: TFunction;
}
export function buildSidebarProjection(input: SidebarProjectionInput): SidebarProjection {
@@ -100,7 +102,7 @@ function buildWorkspaceGroups(
return [];
case "status":
return statusWorkspaceGroups(
buildStatusGroups(unpinnedWorkspaces, input.projectNamesByViewKey),
buildStatusGroups(unpinnedWorkspaces, input.projectNamesByViewKey, input.t),
);
}
}
@@ -408,6 +408,7 @@ function StatusGroupHeader({
group: SidebarWorkspaceGroup;
collapsed: boolean;
}) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const toggleWorkspaceGroupCollapsed = useSidebarCollapsedSectionsStore(
(state) => state.toggleWorkspaceGroupCollapsed,
@@ -431,7 +432,7 @@ function StatusGroupHeader({
<View onPointerEnter={handleHoverIn} onPointerLeave={handleHoverOut}>
<Pressable
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel={`${group.label} group`}
accessibilityLabel={t("sidebar.statusGroupAccessibility", { label: group.label })}
accessibilityState={accessibilityState}
style={rowStyle}
onPress={handlePress}
@@ -380,6 +380,7 @@ export function SidebarWorkspaceContextMenu({
serviceLabel: serviceSummary
? t(workspaceServiceLabelKey(serviceSummary), { name: serviceSummary.name })
: null,
t,
});
const workspaceTarget = useMemo<WorkspaceLabelTarget>(
() => ({
@@ -1,3 +1,4 @@
import { i18n } from "@/i18n/i18next";
import { describe, expect, it } from "vitest";
import {
resolveSidebarWorkspaceAccessibilityLabel,
@@ -39,6 +40,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => {
workspace: { name: "Investigate search", currentBranch: "fix/search", statusBucket: "done" },
workspaceTitleSource: "title",
hostBadgeLabel: "Build host",
t: i18n.t,
});
expect(label).toBe("Investigate search, Build host");
@@ -56,6 +58,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => {
hostBadgeLabel: "Build host",
pullRequestLabel: "Pull request 42",
serviceLabel: "Service web running",
t: i18n.t,
});
expect(label).toBe(
@@ -69,6 +72,7 @@ describe("resolveSidebarWorkspaceAccessibilityLabel", () => {
workspaceTitleSource: "title",
leadingProjectName: "Search project",
hostBadgeLabel: "Build host",
t: i18n.t,
});
expect(label).toBe("Search project, Investigate search, Build host");
@@ -1,6 +1,7 @@
import type { TFunction } from "i18next";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { WorkspaceTitleSource } from "@/hooks/use-settings";
import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model";
import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model";
export function resolveSidebarWorkspacePrimaryLabel(input: {
workspace: Pick<SidebarWorkspaceEntry, "name" | "currentBranch">;
@@ -19,6 +20,7 @@ export function resolveSidebarWorkspaceAccessibilityLabel(input: {
hostBadgeLabel?: string | null;
pullRequestLabel?: string | null;
serviceLabel?: string | null;
t: TFunction;
}): string {
return [
input.leadingProjectName,
@@ -28,7 +30,7 @@ export function resolveSidebarWorkspaceAccessibilityLabel(input: {
input.serviceLabel,
input.workspace.statusBucket === "done"
? null
: STATUS_BUCKET_LABELS[input.workspace.statusBucket],
: getStatusBucketLabel(input.workspace.statusBucket, input.t),
]
.filter((label): label is string => Boolean(label))
.join(", ");
+16 -3
View File
@@ -82,6 +82,8 @@ function WebHoverCard({
const contentRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Read when focus arrives: by focusout the element losing focus no longer matches :focus-visible.
const keyboardFocusedRef = useRef(false);
const clearGraceTimer = useCallback(() => {
if (graceTimerRef.current) {
@@ -98,14 +100,21 @@ function WebHoverCard({
);
}, []);
// Keyboard focus keeps the card open for keyboard users; the focus a pointer click leaves
// behind on the trigger or a button in the card does not outlast the pointer leaving.
const keyboardFocusInside = useCallback(() => {
return focusInside() && document.activeElement?.matches(":focus-visible") === true;
}, [focusInside]);
const scheduleClose = useCallback(() => {
if (focusInside()) return;
if (keyboardFocusInside()) return;
if (graceTimerRef.current) return;
graceTimerRef.current = setTimeout(() => {
graceTimerRef.current = null;
if (keyboardFocusInside()) return;
setOpen(false);
}, CLOSE_GRACE_MS);
}, [focusInside]);
}, [keyboardFocusInside]);
const openNow = useCallback(() => {
clearGraceTimer();
@@ -169,16 +178,20 @@ function WebHoverCard({
setOpen(false);
};
const focusEntered = (event: FocusEvent) => {
const target = event.target as Node;
const target = event.target as Element;
keyboardFocusedRef.current = target.matches(":focus-visible");
if (trigger.contains(target)) {
openNow();
} else if ((contentRef.current as unknown as HTMLElement | null)?.contains(target)) {
clearGraceTimer();
}
};
// Only keyboard focus keeps the card open, so only losing keyboard focus closes it. A button
// that disables itself on press drops pointer focus while the pointer is still on the card.
const focusLeft = (event: FocusEvent) => {
const next = event.relatedTarget as Node | null;
if (
keyboardFocusedRef.current &&
!trigger.contains(next) &&
!(contentRef.current as unknown as HTMLElement | null)?.contains(next)
) {
@@ -55,6 +55,7 @@ import type {
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import {
getFeatureHighlightColor,
isFeatureActive,
getFeatureTooltip,
getAgentControlHintKey,
resolveAgentModelSelection,
@@ -211,8 +212,7 @@ function getModeProviderDefinitions(modeControl: AgentModeControlValue | null) {
}
function getFeatureIconColor(
featureId: string,
enabled: boolean,
feature: AgentFeature,
palette: {
blue: { 400: string };
green: { 400: string };
@@ -220,11 +220,11 @@ function getFeatureIconColor(
},
foregroundMuted: string,
): string {
if (!enabled) {
if (!isFeatureActive(feature)) {
return foregroundMuted;
}
switch (getFeatureHighlightColor(featureId)) {
switch (getFeatureHighlightColor(feature.id)) {
case "blue":
return palette.blue[400];
case "green":
@@ -1335,8 +1335,7 @@ function DesktopFeatureItem({
<AgentControlTrigger
icon={FeatureIcon}
iconColor={getFeatureIconColor(
feature.id,
feature.value,
feature,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
@@ -1370,6 +1369,11 @@ function DesktopFeatureItem({
<AgentControlTrigger
ref={featureAnchorRef}
icon={FeatureIcon}
iconColor={getFeatureIconColor(
feature,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
surface="toolbar"
label={feature.label}
value={selectedOption?.label ?? feature.label}
@@ -1453,8 +1457,7 @@ function SheetFeatureItem({
ref={featureAnchorRef}
icon={FeatureIcon}
iconColor={getFeatureIconColor(
feature.id,
feature.value,
feature,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
@@ -1490,6 +1493,11 @@ function SheetFeatureItem({
<AgentControlTrigger
ref={featureAnchorRef}
icon={FeatureIcon}
iconColor={getFeatureIconColor(
feature,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
surface="sheet"
label={feature.label}
value={selectedOption?.label ?? feature.label}
@@ -3,6 +3,7 @@ import {
getFeatureHighlightColor,
getFeatureTooltip,
getAgentControlHintKey,
isFeatureActive,
normalizeModelId,
resolveAgentModelSelection,
} from "./utils";
@@ -35,9 +36,32 @@ describe("feature metadata helpers", () => {
it("maps feature highlight colors by feature id", () => {
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
expect(getFeatureHighlightColor("service_tier")).toBe("yellow");
expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
expect(getFeatureHighlightColor("other")).toBe("default");
});
it("treats a select feature as active when a non-default option is selected", () => {
const speed = {
type: "select" as const,
id: "service_tier",
label: "Speed",
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "fast", label: "Fast" },
],
};
expect(isFeatureActive({ ...speed, value: "default" })).toBe(false);
expect(isFeatureActive({ ...speed, value: "fast" })).toBe(true);
expect(
isFeatureActive({
type: "toggle",
id: "fast_mode",
label: "Fast",
value: true,
}),
).toBe(true);
});
});
describe("normalizeModelId", () => {
@@ -1,7 +1,11 @@
import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import { i18n } from "@/i18n/i18next";
import { formatThinkingOptionLabel } from "@/agent-controls/labels";
import { FAST_MODE_FEATURE_ID, PLAN_MODE_FEATURE_ID } from "@/agent-controls/policy";
import {
FAST_MODE_FEATURE_ID,
PLAN_MODE_FEATURE_ID,
SPEED_FEATURE_ID,
} from "@/agent-controls/policy";
export type ExplainedAgentControl = "mode" | "model" | "thinking";
export type FeatureHighlightColor = "blue" | "default" | "green" | "yellow";
@@ -35,9 +39,16 @@ export function getFeatureTooltip(feature: Pick<AgentFeature, "label" | "tooltip
return feature.tooltip ?? feature.label;
}
export function isFeatureActive(feature: AgentFeature): boolean {
if (feature.type === "toggle") return feature.value;
const selectedOption = feature.options.find((option) => option.id === feature.value);
return selectedOption !== undefined && !selectedOption.isDefault;
}
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
switch (featureId) {
case FAST_MODE_FEATURE_ID:
case SPEED_FEATURE_ID:
return "yellow";
case "auto_accept":
return "green";
@@ -5,7 +5,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings";
import { KeyboardTranslateView } from "@/keyboard/shift";
import { createContext, useCallback, useContext, type ReactNode } from "react";
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
import { View, type LayoutChangeEvent, type ViewProps, StyleSheet } from "react-native";
import Animated, {
useAnimatedStyle,
@@ -15,7 +15,11 @@ import Animated, {
type SharedValue,
} from "react-native-reanimated";
import { useKeyboardShift } from "@/keyboard/shift";
import { updateComposerCapacity, type ComposerCapacity } from "./internal/capacity";
import {
resolveCenteredClearance,
updateComposerCapacity,
type ComposerCapacity,
} from "./internal/capacity";
const ViewportCapacity = createContext<SharedValue<number | undefined> | null>(null);
@@ -34,11 +38,12 @@ function ComposerViewport({
}: ComposerViewportProps) {
const measuredHeight = useSharedValue(0);
const sizing = useSharedValue<ComposerCapacity | undefined>(undefined);
const { layoutShift } = useKeyboardShift();
const { layoutShift, bottomInset: safeAreaBottom } = useKeyboardShift();
useAnimatedReaction(
() => ({
height: measuredHeight.value,
bottomInset,
safeAreaBottom: safeAreaBottom.value,
keyboardShift: layoutShift.value,
centered,
}),
@@ -79,6 +84,24 @@ function ComposerViewportContent({ style, ...props }: ViewProps) {
);
}
/** The keyboard only needs to move a centered form once it reaches the form's resting bottom. */
function useCenteredClearance(safeAreaBottom: number) {
const [viewportHeight, setViewportHeight] = useState(0);
const [formBottom, setFormBottom] = useState(0);
const measureViewport = useCallback((event: LayoutChangeEvent) => {
setViewportHeight(event.nativeEvent.layout.height);
}, []);
const measureForm = useCallback((event: LayoutChangeEvent) => {
const { y, height } = event.nativeEvent.layout;
setFormBottom(y + height);
}, []);
return {
value: resolveCenteredClearance({ viewportHeight, safeAreaBottom, formBottom }),
measureViewport,
measureForm,
};
}
interface ComposerDockProps {
children: [ReactNode, ReactNode, ReactNode?];
centered?: boolean;
@@ -91,6 +114,7 @@ export function ComposerDock({
}: ComposerDockProps) {
const insets = useSafeAreaInsets();
const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings);
const centeredClearance = useCenteredClearance(insets.bottom);
// Preserve the existing centered form's visual balance on tablets.
const bottomInset = centered ? HEADER_INNER_HEIGHT + 24 : 0;
if (centered) {
@@ -99,8 +123,13 @@ export function ComposerDock({
style={[dockStyles.centeredViewport, { paddingBottom: bottomInset }]}
bottomInset={bottomInset}
centered
onLayout={centeredClearance.measureViewport}
>
<KeyboardTranslateView style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}>
<KeyboardTranslateView
style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}
bottomClearance={centeredClearance.value}
onLayout={centeredClearance.measureForm}
>
<ComposerViewportContent style={dockStyles.composer}>
<ScrollView style={dockStyles.setup} keyboardShouldPersistTaps="handled">
{content}
@@ -1,9 +1,13 @@
import { describe, expect, it } from "vitest";
import { resolveComposerCapacity, updateComposerCapacity } from "./capacity";
import {
resolveCenteredClearance,
resolveComposerCapacity,
updateComposerCapacity,
} from "./capacity";
describe("composer viewport", () => {
it("preserves the editing capacity when the keyboard closes", () => {
const viewport = { height: 582, bottomInset: 24, centered: false };
const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false };
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 });
const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 });
expect(open.capacity).toBe(245);
@@ -15,6 +19,7 @@ describe("composer viewport", () => {
const open = updateComposerCapacity(undefined, {
height: 582,
bottomInset: 24,
safeAreaBottom: 0,
centered: false,
keyboardShift: 308,
});
@@ -22,6 +27,7 @@ describe("composer viewport", () => {
updateComposerCapacity(open, {
height: 650,
bottomInset: 24,
safeAreaBottom: 0,
centered: false,
keyboardShift: 0,
}).capacity,
@@ -30,6 +36,7 @@ describe("composer viewport", () => {
updateComposerCapacity(open, {
height: 0,
bottomInset: 24,
safeAreaBottom: 0,
centered: false,
keyboardShift: 0,
}),
@@ -39,6 +46,7 @@ describe("composer viewport", () => {
const height = resolveComposerCapacity({
height: 582,
bottomInset: 24,
safeAreaBottom: 0,
keyboardShift: 308,
centered: false,
});
@@ -46,23 +54,80 @@ describe("composer viewport", () => {
expect(582 - 24 - 308 - height).toBe(5);
});
it("keeps a centered tablet form below the header after translation", () => {
it("keeps a centered tablet form below the header once the keyboard reaches it", () => {
const viewport = 1000;
const bottomInset = 80;
const keyboardShift = 300;
const height = resolveComposerCapacity({
height: 1000,
bottomInset: 80,
keyboardShift: 300,
height: viewport,
bottomInset,
safeAreaBottom: 0,
keyboardShift,
centered: true,
});
expect(height).toBe(310);
expect((1000 - 80 - height) / 2 - 300).toBe(5);
expect(height).toBe(695);
const restingTop = (viewport - bottomInset - height) / 2;
const bottomClearance = resolveCenteredClearance({
viewportHeight: viewport,
safeAreaBottom: 0,
formBottom: restingTop + height,
});
expect(restingTop - Math.max(0, keyboardShift - bottomClearance)).toBe(5);
});
it("leaves a centered form room to edit on a short foldable viewport", () => {
expect(
resolveComposerCapacity({
height: 725,
bottomInset: 72,
safeAreaBottom: 0,
keyboardShift: 313,
centered: true,
}),
).toBe(407);
});
it("rests a centered form on the keyboard above the bottom safe area", () => {
// The keyboard shift excludes the safe area that the keyboard also covers.
const geometry = {
height: 660,
bottomInset: 60,
safeAreaBottom: 32,
keyboardShift: 288,
centered: true,
};
const height = resolveComposerCapacity(geometry);
const restingTop = (geometry.height - geometry.bottomInset - height) / 2;
const formBottom = restingTop + height;
const clearance = resolveCenteredClearance({
viewportHeight: geometry.height,
safeAreaBottom: geometry.safeAreaBottom,
formBottom,
});
const translation = Math.max(0, geometry.keyboardShift - clearance);
const keyboardTop = geometry.height - geometry.safeAreaBottom - geometry.keyboardShift;
expect(formBottom - translation).toBe(keyboardTop);
expect(restingTop - translation).toBe(5);
});
it("uses the measured viewport before the first keyboard opening", () => {
expect(
resolveComposerCapacity({ height: 582, bottomInset: 24, keyboardShift: 0, centered: false }),
resolveComposerCapacity({
height: 582,
bottomInset: 24,
safeAreaBottom: 0,
keyboardShift: 0,
centered: false,
}),
).toBe(553);
expect(
resolveComposerCapacity({ height: 300, bottomInset: 0, keyboardShift: 200, centered: false }),
resolveComposerCapacity({
height: 300,
bottomInset: 0,
safeAreaBottom: 0,
keyboardShift: 200,
centered: false,
}),
).toBe(95);
});
});
@@ -1,6 +1,8 @@
interface ComposerGeometry {
height: number;
bottomInset: number;
/** The keyboard also covers this space below its shift. */
safeAreaBottom: number;
keyboardShift: number;
centered: boolean;
}
@@ -12,11 +14,25 @@ export interface ComposerCapacity {
export function resolveComposerCapacity(input: ComposerGeometry): number {
"worklet";
// A centered form grows upward by half its height. Reserve both halves so
// translating it still leaves five layout points below the header.
const clearance = input.keyboardShift + 5;
const reservedSpace = input.centered ? clearance * 2 : clearance;
return Math.max(0, input.height - input.bottomInset - reservedSpace);
// A centered form only translates by the keyboard overlap, so once the keyboard
// reaches it the form sits on the keyboard and fits above it, not twice over.
// A bottom-anchored composer pads the safe area inside its own capacity.
const available = input.centered
? Math.min(
input.height - input.bottomInset,
input.height - input.safeAreaBottom - input.keyboardShift,
)
: input.height - input.bottomInset - input.keyboardShift;
return Math.max(0, available - 5);
}
/** Space below a centered form that the keyboard fills before the form moves. */
export function resolveCenteredClearance(input: {
viewportHeight: number;
safeAreaBottom: number;
formBottom: number;
}): number {
return Math.max(0, input.viewportHeight - input.safeAreaBottom - input.formBottom);
}
export function updateComposerCapacity(
@@ -1,6 +1,7 @@
import { useCallback, useMemo } from "react";
import { Pressable, ScrollView, Text, View, type GestureResponderEvent } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, MessageSquarePlus } from "lucide-react-native";
import { Button } from "@/components/ui/button";
import { openExternalUrl } from "@/utils/open-external-url";
@@ -71,7 +72,8 @@ export function ChecksSection({
loadingCheckKeys,
onAddLogsToChat,
}: ChecksSectionProps) {
const summary = useMemo(() => summarizeChecks(checks), [checks]);
const { t } = useTranslation();
const summary = useMemo(() => summarizeChecks(checks, t), [checks, t]);
const { collapsedGroups, toggle: handleToggleGroup } = useCheckGroupState();
return (
@@ -91,13 +93,14 @@ export function ChecksSection({
</Text>
{summary.parts.length > 0 ? (
<Text style={styles.detail} numberOfLines={1} testID="pr-pane-check-summary">
{summary.detailLead}
{summary.parts.map((part, index) => (
<Text key={part.status}>
{index > 0 ? ", " : ""}
<Text testID={PART_TEST_ID[part.status]}>{part.text}</Text>
</Text>
))}
{` ${summary.countNoun}`}
{summary.detailTrail}
</Text>
) : null}
</View>
@@ -186,6 +189,7 @@ function CheckRow({
isAddingLogsToChat: boolean;
onAddLogsToChat: (check: PrPaneCheck) => void;
}) {
const { t } = useTranslation();
const handlePress = useCallback(() => {
void openExternalUrl(check.url);
}, [check.url]);
@@ -217,7 +221,9 @@ function CheckRow({
onPress={handleAddLogsToChat}
style={styles.addButton}
>
{isAddingLogsToChat ? "Adding..." : "Add to chat"}
{isAddingLogsToChat
? t("workspace.git.pr.actions.addingToChat")
: t("workspace.git.pr.actions.addToChat")}
</Button>
) : null}
{check.timing && <Text style={sectionKitStyles.checkDuration}>{check.timing}</Text>}
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import type { CheckStatus } from "./check-status";
import { summarizeChecks } from "./checks-summary";
import type { PrPaneCheck } from "./data";
@@ -9,7 +10,7 @@ function check(name: string, status: CheckStatus): PrPaneCheck {
describe("summarizeChecks", () => {
it("reports no checks without claiming the run passed", () => {
const summary = summarizeChecks([]);
const summary = summarizeChecks([], i18n.t);
expect(summary.outcome).toBe("none");
expect(summary.headline).toBe("No checks");
@@ -18,11 +19,10 @@ describe("summarizeChecks", () => {
});
it("leads with failures when anything failed", () => {
const summary = summarizeChecks([
check("build", "success"),
check("lint", "failure"),
check("e2e", "pending"),
]);
const summary = summarizeChecks(
[check("build", "success"), check("lint", "failure"), check("e2e", "pending")],
i18n.t,
);
expect(summary.outcome).toBe("failure");
expect(summary.headline).toBe("Some checks were not successful");
@@ -31,7 +31,7 @@ describe("summarizeChecks", () => {
});
it("reports an unfinished run as in progress when nothing failed", () => {
const summary = summarizeChecks([check("build", "success"), check("e2e", "pending")]);
const summary = summarizeChecks([check("build", "success"), check("e2e", "pending")], i18n.t);
expect(summary.outcome).toBe("pending");
expect(summary.headline).toBe("Some checks haven't completed yet");
@@ -39,7 +39,10 @@ describe("summarizeChecks", () => {
});
it("treats a run of only successes and skips as passed", () => {
const summary = summarizeChecks([check("build", "success"), check("deploy", "skipped")]);
const summary = summarizeChecks(
[check("build", "success"), check("deploy", "skipped")],
i18n.t,
);
expect(summary.outcome).toBe("success");
expect(summary.headline).toBe("All checks have passed");
@@ -47,11 +50,10 @@ describe("summarizeChecks", () => {
});
it("keeps the checks of each group together under a counted label", () => {
const summary = summarizeChecks([
check("build", "failure"),
check("lint", "success"),
check("test", "failure"),
]);
const summary = summarizeChecks(
[check("build", "failure"), check("lint", "success"), check("test", "failure")],
i18n.t,
);
const [failing, successful] = summary.groups;
expect(failing?.label).toBe("2 failing checks");
@@ -60,9 +62,34 @@ describe("summarizeChecks", () => {
});
it("says check rather than checks for a run of one", () => {
const summary = summarizeChecks([check("build", "failure")]);
const summary = summarizeChecks([check("build", "failure")], i18n.t);
expect(summary.countNoun).toBe("check");
expect(summary.detail).toBe("1 failing check");
});
it("reads in the active app language", async () => {
await i18n.changeLanguage("fr");
try {
const summary = summarizeChecks(
[
check("build", "success"),
check("deploy", "skipped"),
check("lint", "failure"),
check("test", "failure"),
],
i18n.t,
);
expect(summary.headline).toBe("Certaines vérifications ont échoué");
expect(summary.detail).toBe("Vérifications : 2 en échec, 1 réussie(s), 1 ignorée(s)");
expect(summary.groups.map((group) => group.label)).toEqual([
"2 vérifications en échec",
"1 vérification réussie",
"1 vérification ignorée",
]);
expect(summarizeChecks([], i18n.t).headline).toBe("Aucune vérification");
} finally {
await i18n.changeLanguage("en");
}
});
});
@@ -1,4 +1,5 @@
import { classifyCheck, type CheckPresentation } from "@/git/check-presentation";
import type { TFunction } from "i18next";
import type { PrPaneCheck } from "./data";
/**
@@ -16,27 +17,10 @@ const PRESENTATION_ORDER = [
"ignored",
] as const satisfies readonly CheckPresentation[];
/** How each status reads inside a count phrase: "2 failing", "4 in progress". */
const PRESENTATION_NOUN: Record<CheckPresentation, string> = {
actionRequired: "needs action",
warning: "warning",
failure: "failing",
pending: "in progress",
manual: "manual",
success: "successful",
ignored: "skipped",
};
/** The worst thing happening in the run, which is what the headline and the ring report. */
export type ChecksOutcome = "actionRequired" | "failure" | "pending" | "success" | "none";
const OUTCOME_HEADLINE: Record<ChecksOutcome, string> = {
actionRequired: "Some checks need your attention",
failure: "Some checks were not successful",
pending: "Some checks haven't completed yet",
success: "All checks have passed",
none: "No checks",
};
const COPY = "workspace.git.pr.checksOverview";
export interface ChecksCountPart {
status: CheckPresentation;
@@ -58,8 +42,12 @@ export interface ChecksSummary {
headline: string;
/** The count phrases behind `detail`, kept apart so the header can label each one. */
parts: readonly ChecksCountPart[];
/** The noun closing the detail line — "checks", or "check" for a run of one. */
countNoun: string;
/**
* The words around the count phrases in `detail`: "" and " checks" in English, where the noun
* closes the line, while other languages lead with it.
*/
detailLead: string;
detailTrail: string;
/** The whole line: "2 failing, 4 in progress, 1 successful checks". Empty with no checks. */
detail: string;
total: number;
@@ -72,7 +60,7 @@ export interface ChecksSummary {
* header, the ring, and the grouped list all read from one derivation instead of each
* filtering the array again with its own idea of what counts.
*/
export function summarizeChecks(checks: readonly PrPaneCheck[]): ChecksSummary {
export function summarizeChecks(checks: readonly PrPaneCheck[], t: TFunction): ChecksSummary {
const groups: ChecksGroup[] = [];
const parts: ChecksCountPart[] = [];
@@ -83,24 +71,27 @@ export function summarizeChecks(checks: readonly PrPaneCheck[]): ChecksSummary {
}
groups.push({
status,
label: `${matching.length} ${PRESENTATION_NOUN[status]} ${countNoun(matching.length)}`,
label: t(`${COPY}.${matching.length === 1 ? "groupOne" : "groupMany"}.${status}`, {
count: matching.length,
}),
checks: matching,
});
parts.push({
status,
count: matching.length,
text: `${matching.length} ${PRESENTATION_NOUN[status]}`,
text: t(`${COPY}.count.${status}`, { count: matching.length }),
});
}
const outcome = selectOutcome(checks);
const noun = countNoun(checks.length);
const { lead, trail } = detailFrame(checks.length, t);
return {
outcome,
headline: OUTCOME_HEADLINE[outcome],
headline: t(`${COPY}.headline.${outcome}`),
parts,
countNoun: noun,
detail: parts.length === 0 ? "" : `${parts.map((part) => part.text).join(", ")} ${noun}`,
detailLead: lead,
detailTrail: trail,
detail: parts.length === 0 ? "" : `${lead}${parts.map((part) => part.text).join(", ")}${trail}`,
total: checks.length,
groups,
};
@@ -131,6 +122,13 @@ function selectOutcome(checks: readonly PrPaneCheck[]): ChecksOutcome {
return "success";
}
function countNoun(count: number): string {
return count === 1 ? "check" : "checks";
/**
* The header renders each count phrase on its own, so the translated line is split around
* where the phrases go.
*/
function detailFrame(total: number, t: TFunction): { lead: string; trail: string } {
const marker = "\u0000";
const line = t(`${COPY}.${total === 1 ? "detailOne" : "detailMany"}`, { parts: marker });
const [lead = "", trail = ""] = line.split(marker);
return { lead, trail };
}
@@ -9,12 +9,13 @@ import {
isPipelineActiveStatus,
mapPipelineStatus,
} from "@/git/forges/gitlab";
import { i18n } from "@/i18n/i18next";
import { IDENTITY_COLOR_NAMES, identityColor } from "@/styles/identity-colors";
import {
deriveAvatarColor,
formatAge,
getActivityVerb,
getStateLabel,
getActivityVerbKey,
getStateLabelKey,
mapPrPaneData,
} from "./data";
@@ -779,37 +780,34 @@ describe("formatAge", () => {
});
});
describe("getStateLabel", () => {
const english = i18n.getFixedT("en");
const french = i18n.getFixedT("fr");
describe("getStateLabelKey", () => {
it.each([
["open", "Open"],
["draft", "Draft"],
["merged", "Merged"],
["closed", "Closed"],
] as const)("maps %s → %s", (state, expected) => {
expect(getStateLabel(state)).toBe(expected);
["open", "Open", "Ouverte"],
["draft", "Draft", "Brouillon"],
["merged", "Merged", "Fusionnée"],
["closed", "Closed", "Fermée"],
] as const)("labels %s as %s, and %s in French", (state, englishLabel, frenchLabel) => {
expect(english(getStateLabelKey(state))).toBe(englishLabel);
expect(french(getStateLabelKey(state))).toBe(frenchLabel);
});
});
describe("getActivityVerb", () => {
it("returns Commented for comment kind", () => {
expect(getActivityVerb({ kind: "comment" })).toBe("Commented");
});
it("returns Approved for approved review", () => {
expect(getActivityVerb({ kind: "review", reviewState: "approved" })).toBe("Approved");
});
it("returns Requested changes for changes_requested review", () => {
expect(getActivityVerb({ kind: "review", reviewState: "changes_requested" })).toBe(
describe("getActivityVerbKey", () => {
it.each([
[{ kind: "comment" }, "Commented", "A commenté"],
[{ kind: "review", reviewState: "approved" }, "Approved", "A approuvé"],
[
{ kind: "review", reviewState: "changes_requested" },
"Requested changes",
);
});
it("returns Reviewed for a commented review with body (generic case)", () => {
expect(getActivityVerb({ kind: "review", reviewState: "commented" })).toBe("Reviewed");
});
it("returns Reviewed when reviewState is undefined", () => {
expect(getActivityVerb({ kind: "review" })).toBe("Reviewed");
"A demandé des modifications",
],
[{ kind: "review", reviewState: "commented" }, "Reviewed", "A relu"],
[{ kind: "review" }, "Reviewed", "A relu"],
] as const)("labels %o as %s, and %s in French", (item, englishVerb, frenchVerb) => {
expect(english(getActivityVerbKey(item))).toBe(englishVerb);
expect(french(getActivityVerbKey(item))).toBe(frenchVerb);
});
});
+12 -10
View File
@@ -318,16 +318,18 @@ function parsePullRequestNumber(url: string): number | null {
}
}
export function getStateLabel(state: PrState): string {
if (state === "draft") return "Draft";
if (state === "merged") return "Merged";
if (state === "closed") return "Closed";
return "Open";
export function getStateLabelKey(state: PrState) {
if (state === "draft") return "workspace.git.pr.states.draft";
if (state === "merged") return "workspace.git.pr.states.merged";
if (state === "closed") return "workspace.git.pr.states.closed";
return "workspace.git.pr.states.open";
}
export function getActivityVerb(item: Pick<PrPaneActivity, "kind" | "reviewState">): string {
if (item.kind === "comment") return "Commented";
if (item.reviewState === "approved") return "Approved";
if (item.reviewState === "changes_requested") return "Requested changes";
return "Reviewed";
export function getActivityVerbKey(item: Pick<PrPaneActivity, "kind" | "reviewState">) {
if (item.kind === "comment") return "workspace.git.pr.activity.commented";
if (item.reviewState === "approved") return "workspace.git.pr.activity.approved";
if (item.reviewState === "changes_requested") {
return "workspace.git.pr.activity.requestedChanges";
}
return "workspace.git.pr.activity.reviewed";
}
@@ -63,7 +63,7 @@ import {
canAddPullRequestActivityToChat,
} from "./context-attachment";
import { ChecksSection, getCheckIdentity } from "./checks-section";
import { getActivityVerb, getStateLabel } from "./data";
import { getActivityVerbKey, getStateLabelKey } from "./data";
import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data";
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
import { CheckPresentationIcon } from "@/git/check-presentation.view";
@@ -538,7 +538,7 @@ export function PullRequestPane({
<View style={styles.metaLine}>
<StateIcon size={14} uniProps={statePresentation.iconColor} />
<Text style={stateLabelStyle(data.state)} testID="pr-pane-state">
{getStateLabel(data.state)}
{t(getStateLabelKey(data.state))}
</Text>
{nativeHeaderMeta}
{repoIdentity ? (
@@ -568,7 +568,7 @@ export function PullRequestPane({
<View style={styles.divider} />
<Section
title="Activity"
title={t("workspace.git.pr.sections.activity")}
open={activityOpen}
onToggle={handleToggleActivity}
summary={
@@ -592,13 +592,13 @@ export function PullRequestPane({
onPress={handleAddAllToChat}
disabled={activityLoading}
>
Add all to chat
{t("workspace.git.pr.actions.addAllToChat")}
</Button>
</View>
) : null}
{activityLoading ? <PrActivitySkeleton /> : null}
{!activityLoading && visibleEntries.length === 0 ? (
<Text style={sectionKitStyles.emptyText}>No activity yet</Text>
<Text style={sectionKitStyles.emptyText}>{t("workspace.git.pr.empty.noActivity")}</Text>
) : null}
{!activityLoading
? visibleEntries.map(({ entry, collapsed }) => (
@@ -702,14 +702,14 @@ function ActivityKebab({
<DropdownMenuTrigger
hitSlop={8}
style={kebabTriggerStyle}
accessibilityLabel="Comment actions"
accessibilityLabel={t("workspace.git.pr.accessibility.commentActions")}
>
{renderKebabTriggerIcon}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={200}>
{attachEnabled && canAddPullRequestActivityToChat(activity) ? (
<DropdownMenuItem leading={ADD_TO_CHAT_MENU_ICON} onSelect={handleAddToChat}>
Add to chat
{t("workspace.git.pr.actions.addToChat")}
</DropdownMenuItem>
) : null}
{activity.body.trim() !== "" ? (
@@ -760,7 +760,8 @@ function ActivityAvatar({ activity, size }: { activity: PrPaneActivity; size: nu
}
function ActivityVerb({ activity }: { activity: PrPaneActivity }) {
const verb = getActivityVerb(activity).toLowerCase();
const { t } = useTranslation();
const verb = t(getActivityVerbKey(activity)).toLowerCase();
if (activity.kind === "review" && activity.reviewState === "approved") {
return (
<View style={styles.verbGroup}>
@@ -815,6 +816,7 @@ function SingleActivityCard({
entry: Extract<PrTimelineEntry, { kind: "single" }>;
collapsed: boolean;
}) {
const { t } = useTranslation();
const { activity } = entry;
const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } =
useRevealOnHover();
@@ -884,7 +886,7 @@ function SingleActivityCard({
leftIcon={MessageSquarePlus}
onPress={handleAddToChat}
>
Add to chat
{t("workspace.git.pr.actions.addToChat")}
</Button>
</View>
) : null}
@@ -935,6 +937,7 @@ function ReviewCard({
collapsed: boolean;
collapsedEntryIds: ReadonlySet<string>;
}) {
const { t } = useTranslation();
const { review, threads } = entry;
const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } =
useRevealOnHover();
@@ -961,7 +964,7 @@ function ReviewCard({
onPress={handleAddToChat}
style={styles.checkAddButton}
>
Add to chat
{t("workspace.git.pr.actions.addToChat")}
</Button>
) : null}
{collapsed ? (
@@ -995,7 +998,7 @@ function ReviewCard({
leftIcon={MessageSquarePlus}
onPress={handleAddToChat}
>
Add to chat
{t("workspace.git.pr.actions.addToChat")}
</Button>
</View>
) : null}
@@ -1063,8 +1066,12 @@ function ThreadBlock({
? formatPullRequestThreadPath(thread.location)
: t("workspace.git.pr.thread.discussion")}
</Text>
{thread.isResolved ? <StatusBadge label="Resolved" variant="success" /> : null}
{thread.location?.isOutdated ? <StatusBadge label="Outdated" /> : null}
{thread.isResolved ? (
<StatusBadge label={t("workspace.git.pr.thread.resolved")} variant="success" />
) : null}
{thread.location?.isOutdated ? (
<StatusBadge label={t("workspace.git.pr.thread.outdated")} />
) : null}
<View style={styles.headerTrailing}>
{collapsed ? (
<View style={styles.threadCount}>
@@ -1080,7 +1087,7 @@ function ThreadBlock({
<DropdownMenuTrigger
hitSlop={8}
style={kebabTriggerStyle}
accessibilityLabel="Thread actions"
accessibilityLabel={t("workspace.git.pr.accessibility.threadActions")}
>
{renderKebabTriggerIcon}
</DropdownMenuTrigger>
@@ -1124,7 +1131,7 @@ function ThreadBlock({
leftIcon={MessageSquarePlus}
onPress={handleAddThreadToChat}
>
Add to chat
{t("workspace.git.pr.actions.addToChat")}
</Button>
</View>
) : null}
@@ -1,9 +1,10 @@
import { describe, expect, it } from "vitest";
import { i18n } from "@/i18n/i18next";
import type { SidebarWorkspaceEntry } from "./sidebar-workspaces-view-model";
import {
buildStatusGroups,
buildStatusShortcutIndex,
STATUS_BUCKET_LABELS,
getStatusBucketLabel,
STATUS_BUCKET_ORDER,
type StatusGroup,
} from "./sidebar-status-view-model";
@@ -55,7 +56,7 @@ describe("buildStatusGroups", () => {
ws({ workspaceKey: "srv:running-ws", statusBucket: "running", name: "running-ws" }),
];
const groups = buildStatusGroups(workspaces, emptyProjectNames);
const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t);
expect(groups.map((g) => g.bucket)).toEqual(["needs_input", "running", "done"]);
expect(groups[0]?.label).toBe("Needs input");
@@ -69,7 +70,7 @@ describe("buildStatusGroups", () => {
ws({ workspaceKey: "srv:b", statusBucket: "running" }),
];
const groups = buildStatusGroups(workspaces, emptyProjectNames);
const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t);
expect(groups.map((g) => g.bucket)).toEqual(["running", "done"]);
});
@@ -93,7 +94,7 @@ describe("buildStatusGroups", () => {
}),
];
const groups = buildStatusGroups(workspaces, emptyProjectNames);
const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t);
expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual(["srv:new", "srv:mid", "srv:old"]);
});
@@ -109,7 +110,7 @@ describe("buildStatusGroups", () => {
ws({ workspaceKey: "srv:null-b", statusBucket: "done", statusEnteredAt: null }),
];
const groups = buildStatusGroups(workspaces, emptyProjectNames);
const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t);
expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual([
"srv:ts",
@@ -145,13 +146,13 @@ describe("buildStatusGroups", () => {
}),
];
const groups = buildStatusGroups(workspaces, projectNames);
const groups = buildStatusGroups(workspaces, projectNames, i18n.t);
expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual(["srv:2", "srv:3", "srv:1"]);
});
it("returns empty array for no workspaces", () => {
const groups = buildStatusGroups([], emptyProjectNames);
const groups = buildStatusGroups([], emptyProjectNames, i18n.t);
expect(groups).toEqual([]);
});
@@ -180,11 +181,11 @@ describe("buildStatusGroups", () => {
ws({ workspaceKey: "srv:dn", statusBucket: "done", statusEnteredAt: null }),
];
const groups = buildStatusGroups(workspaces, emptyProjectNames);
const groups = buildStatusGroups(workspaces, emptyProjectNames, i18n.t);
expect(groups.map((g) => g.bucket)).toEqual(STATUS_BUCKET_ORDER);
expect(groups.map((g) => g.label)).toEqual(
STATUS_BUCKET_ORDER.map((b) => STATUS_BUCKET_LABELS[b]),
STATUS_BUCKET_ORDER.map((b) => getStatusBucketLabel(b, i18n.t)),
);
// Each group has exactly one row with the matching bucket
for (const group of groups) {
@@ -230,3 +231,20 @@ describe("buildStatusShortcutIndex", () => {
expect(index.size).toBe(0);
});
});
describe("getStatusBucketLabel", () => {
it("reads in the active app language", async () => {
await i18n.changeLanguage("fr");
try {
expect(STATUS_BUCKET_ORDER.map((bucket) => getStatusBucketLabel(bucket, i18n.t))).toEqual([
"Attend une réponse",
"Échec",
"À relire",
"En cours",
"Terminé",
]);
} finally {
await i18n.changeLanguage("en");
}
});
});
@@ -1,3 +1,4 @@
import type { TFunction } from "i18next";
import { STATUS_BUCKET_ORDER } from "@/utils/sidebar-agent-state";
import type { SidebarWorkspaceEntry } from "@/hooks/sidebar-workspaces-view-model";
@@ -5,13 +6,17 @@ export type StatusBucket = SidebarWorkspaceEntry["statusBucket"];
export { STATUS_BUCKET_ORDER };
export const STATUS_BUCKET_LABELS: Record<StatusBucket, string> = {
needs_input: "Needs input",
failed: "Failed",
attention: "Ready to review",
running: "Working",
done: "Done",
};
const STATUS_BUCKET_LABEL_KEYS = {
needs_input: "sidebar.statusBucket.needsInput",
failed: "sidebar.statusBucket.failed",
attention: "sidebar.statusBucket.readyToReview",
running: "sidebar.statusBucket.working",
done: "sidebar.statusBucket.done",
} as const satisfies Record<StatusBucket, string>;
export function getStatusBucketLabel(bucket: StatusBucket, t: TFunction): string {
return t(STATUS_BUCKET_LABEL_KEYS[bucket]);
}
export interface StatusGroup {
bucket: StatusBucket;
@@ -22,6 +27,7 @@ export interface StatusGroup {
export function buildStatusGroups(
workspaces: SidebarWorkspaceEntry[],
projectNamesByViewKey: Map<string, string>,
t: TFunction,
): StatusGroup[] {
const bucketRows = new Map<StatusBucket, SidebarWorkspaceEntry[]>();
@@ -42,7 +48,7 @@ export function buildStatusGroups(
if (!rows || rows.length === 0) continue;
rows.sort((a, b) => compareStatusRows(a, b, projectNamesByViewKey));
groups.push({ bucket, label: STATUS_BUCKET_LABELS[bucket], rows });
groups.push({ bucket, label: getStatusBucketLabel(bucket, t), rows });
}
return groups;
+82
View File
@@ -74,6 +74,45 @@ const untranslatedLocalFallbacks = [
"Unable to save desktop settings.",
] as const;
const pullRequestPanelSources = [
"git/pull-request-panel/pane.tsx",
"git/pull-request-panel/data.ts",
"git/pull-request-panel/checks-section.tsx",
"components/sidebar/sidebar-status-list.tsx",
] as const;
const untranslatedPullRequestPanelLabels = [
"Activity",
"No activity yet",
"Add all to chat",
"Add to chat",
"Adding...",
"Comment actions",
"Thread actions",
"Commented",
"Approved",
"Requested changes",
"Reviewed",
"Draft",
"Merged",
"Closed",
"Open",
"Resolved",
"Outdated",
] as const;
function findUntranslatedPullRequestPanelLabels(): string[] {
return pullRequestPanelSources.flatMap((source) => {
const contents = readFileSync(join(appSourceRoot, source), "utf8");
const matches: string[] = untranslatedPullRequestPanelLabels.filter(
(text) => contents.includes(`"${text}"`) || new RegExp(`>\\s*${text}\\s*<`).test(contents),
);
if (contents.includes("} group`")) {
matches.push("group");
}
return matches.length === 0 ? [] : [`${source}: ${matches.join(", ")}`];
});
}
function collectSourceFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name);
@@ -183,10 +222,53 @@ describe("translation resources", () => {
expect(ko.desktop.daemon.status.notRunning).toBe("실행 중이 아님");
});
it("uses the French navigation and Git meanings for reported labels", () => {
expect(fr.common.back).toBe("Retour");
expect(fr.common.actions.back).toBe("Retour");
expect(fr.common.actions.copy).toBe("Copier");
expect(fr.common.states.starting).toBe("Démarrage…");
expect(fr.common.connectionStatus.connecting).toBe("Connexion…");
expect(fr.workspace.git.pr.sections.checks).toBe("Vérifications");
expect(fr.sidebar.display.show.checks).toBe("Vérifications");
});
it("separates French interpolation placeholders from neighboring words", () => {
const glued = Object.entries(flattenStrings(fr)).filter(([, value]) =>
/\p{L}\{\{|\}\}\p{L}{2,}/u.test(value),
);
expect(glued).toEqual([]);
});
it("labels the immediate add-to-chat action without an ellipsis", () => {
expect(en.workspace.fileActions.addToChat).toBe("Add to chat");
});
it("keeps pull request panel and sidebar status group labels translated", () => {
expect(findUntranslatedPullRequestPanelLabels()).toEqual([]);
for (const resource of [ar, es, fr, ja, ko, ptBR, ru, zhCN]) {
const pr = resource.workspace.git.pr;
const englishPr = en.workspace.git.pr;
expect(pr.sections.activity).not.toBe(englishPr.sections.activity);
expect(pr.empty.noActivity).not.toBe(englishPr.empty.noActivity);
expect(pr.actions.addToChat).not.toBe(englishPr.actions.addToChat);
expect(pr.actions.addAllToChat).not.toBe(englishPr.actions.addAllToChat);
expect(pr.actions.addingToChat).not.toBe(englishPr.actions.addingToChat);
expect(pr.accessibility.commentActions).not.toBe(englishPr.accessibility.commentActions);
expect(pr.accessibility.threadActions).not.toBe(englishPr.accessibility.threadActions);
for (const verb of Object.keys(englishPr.activity) as (keyof typeof englishPr.activity)[]) {
expect(pr.activity[verb]).not.toBe(englishPr.activity[verb]);
}
for (const state of Object.keys(englishPr.states) as (keyof typeof englishPr.states)[]) {
expect(pr.states[state]).not.toBe(englishPr.states[state]);
}
expect(pr.thread.resolved).not.toBe(englishPr.thread.resolved);
expect(pr.thread.outdated).not.toBe(englishPr.thread.outdated);
expect(resource.sidebar.statusGroupAccessibility).not.toBe(
en.sidebar.statusGroupAccessibility,
);
}
});
it("keeps local connection fallback errors translated", () => {
expect(findUntranslatedConnectionErrors()).toEqual([]);
});
+64
View File
@@ -382,6 +382,9 @@ export const ar: TranslationResources = {
completed: "اكتملت",
},
},
turnFooter: {
workedFor: "عمل لمدة {{duration}}",
},
compaction: {
loading: "الضغط...",
auto: "يتم ضغط السياق تلقائيًا",
@@ -970,6 +973,47 @@ export const ar: TranslationResources = {
actions: {
viewPullRequest: "عرض",
openOn: "فتح على {{brand}}",
addToChat: "إضافة إلى الدردشة",
addAllToChat: "إضافة الكل إلى الدردشة",
addingToChat: "جارٍ الإضافة...",
},
checksOverview: {
headline: {
actionRequired: "بعض الفحوصات تحتاج إلى انتباهك",
failure: "بعض الفحوصات لم تنجح",
pending: "بعض الفحوصات لم تكتمل بعد",
success: "نجحت جميع الفحوصات",
none: "لا توجد فحوصات",
},
count: {
actionRequired: "{{count}} تحتاج إجراءً",
warning: "{{count}} مع تحذير",
failure: "{{count}} فاشلة",
pending: "{{count}} قيد التشغيل",
manual: "{{count}} يدوية",
success: "{{count}} ناجحة",
ignored: "{{count}} متخطاة",
},
detailOne: "الفحص: {{parts}}",
detailMany: "الفحوصات: {{parts}}",
groupOne: {
actionRequired: "{{count}} فحص يحتاج إجراءً",
warning: "{{count}} فحص مع تحذير",
failure: "{{count}} فحص فاشل",
pending: "{{count}} فحص قيد التشغيل",
manual: "{{count}} فحص يدوي",
success: "{{count}} فحص ناجح",
ignored: "{{count}} فحص متخطى",
},
groupMany: {
actionRequired: "{{count}} فحوصات تحتاج إجراءً",
warning: "{{count}} فحوصات مع تحذير",
failure: "{{count}} فحوصات فاشلة",
pending: "{{count}} فحوصات قيد التشغيل",
manual: "{{count}} فحوصات يدوية",
success: "{{count}} فحوصات ناجحة",
ignored: "{{count}} فحوصات متخطاة",
},
},
checksSummary: {
passedLabel: "نجح",
@@ -983,17 +1027,21 @@ export const ar: TranslationResources = {
checks: "الشيكات",
pipeline: "خط المعالجة",
reviews: "التعليقات",
activity: "النشاط",
},
empty: {
noJobs: "لا توجد مهام",
loadingPipeline: "جارٍ تحميل خط المعالجة...",
pipelineJobsLoadFailed: "تعذر تحميل مهام خط المعالجة",
allowedToFail: "مسموح بالفشل",
noActivity: "لا يوجد نشاط بعد",
},
approvals: "{{given}} من {{required}} موافقات",
accessibility: {
pullRequest: "سحب الطلب #{{number}}",
pullRequest_mr: "طلب دمج !{{number}}",
commentActions: "إجراءات التعليق",
threadActions: "إجراءات سلسلة النقاش",
checkStatus: {
passed: "ناجح",
failed: "فاشل",
@@ -1022,6 +1070,8 @@ export const ar: TranslationResources = {
},
thread: {
discussion: "سلسلة المناقشة",
resolved: "تم الحل",
outdated: "قديم",
},
errors: {
statusLoadFailed: "غير قادر على تحميل حالة طلب السحب",
@@ -1088,6 +1138,14 @@ export const ar: TranslationResources = {
},
},
sidebar: {
statusGroupAccessibility: "مجموعة {{label}}",
statusBucket: {
needsInput: "تحتاج إدخالاً",
failed: "فشل",
readyToReview: "جاهزة للمراجعة",
working: "قيد العمل",
done: "تم",
},
display: {
trigger: "تفضيلات العرض",
heading: "العرض",
@@ -1703,6 +1761,12 @@ export const ar: TranslationResources = {
helper: "الاتصال بخادم Paseo يعمل على المضيف البعيد.",
fields: {
target: "مضيف SSH",
password: "كلمة مرور الدايمون",
optional: "خياري",
},
passwordVisibility: {
show: "إظهار كلمة المرور",
hide: "إخفاء كلمة المرور",
},
actions: {
cancel: "إلغاء",
+65 -1
View File
@@ -379,6 +379,9 @@ export const en = {
completed: "Completed",
},
},
turnFooter: {
workedFor: "Worked for {{duration}}",
},
compaction: {
loading: "Compacting...",
auto: "Context automatically compacted",
@@ -978,6 +981,47 @@ export const en = {
actions: {
viewPullRequest: "View",
openOn: "Open on {{brand}}",
addToChat: "Add to chat",
addAllToChat: "Add all to chat",
addingToChat: "Adding...",
},
checksOverview: {
headline: {
actionRequired: "Some checks need your attention",
failure: "Some checks were not successful",
pending: "Some checks haven't completed yet",
success: "All checks have passed",
none: "No checks",
},
count: {
actionRequired: "{{count}} needs action",
warning: "{{count}} warning",
failure: "{{count}} failing",
pending: "{{count}} in progress",
manual: "{{count}} manual",
success: "{{count}} successful",
ignored: "{{count}} skipped",
},
detailOne: "{{parts}} check",
detailMany: "{{parts}} checks",
groupOne: {
actionRequired: "{{count}} needs action check",
warning: "{{count}} warning check",
failure: "{{count}} failing check",
pending: "{{count}} in progress check",
manual: "{{count}} manual check",
success: "{{count}} successful check",
ignored: "{{count}} skipped check",
},
groupMany: {
actionRequired: "{{count}} needs action checks",
warning: "{{count}} warning checks",
failure: "{{count}} failing checks",
pending: "{{count}} in progress checks",
manual: "{{count}} manual checks",
success: "{{count}} successful checks",
ignored: "{{count}} skipped checks",
},
},
checksSummary: {
passedLabel: "passed",
@@ -991,17 +1035,21 @@ export const en = {
checks: "Checks",
pipeline: "Pipeline",
reviews: "Reviews",
activity: "Activity",
},
empty: {
noJobs: "No jobs",
loadingPipeline: "Loading pipeline…",
pipelineJobsLoadFailed: "Could not load pipeline jobs",
allowedToFail: "allowed to fail",
noActivity: "No activity yet",
},
approvals: "{{given}} of {{required}} approvals",
accessibility: {
pullRequest: "Pull request #{{number}}",
pullRequest_mr: "Merge request !{{number}}",
commentActions: "Comment actions",
threadActions: "Thread actions",
checkStatus: {
passed: "Passed",
failed: "Failed",
@@ -1030,6 +1078,8 @@ export const en = {
},
thread: {
discussion: "Discussion thread",
resolved: "Resolved",
outdated: "Outdated",
},
errors: {
statusLoadFailed: "Unable to load pull request status",
@@ -1096,6 +1146,14 @@ export const en = {
},
},
sidebar: {
statusGroupAccessibility: "{{label}} group",
statusBucket: {
needsInput: "Needs input",
failed: "Failed",
readyToReview: "Ready to review",
working: "Working",
done: "Done",
},
display: {
trigger: "Display preferences",
heading: "Display",
@@ -1727,6 +1785,12 @@ export const en = {
helper: "Connect to a Paseo daemon running on the remote host.",
fields: {
target: "SSH host",
password: "Daemon password",
optional: "Optional",
},
passwordVisibility: {
show: "Show password",
hide: "Hide password",
},
actions: {
cancel: "Cancel",
@@ -2098,7 +2162,7 @@ export const en = {
globalTitle: "Enable plugins",
globalHint: "Global switch for every configured plugin",
sourceLabel: "Plugin source",
sourcePlaceholder: "Directory, Git URL, or npm package",
sourcePlaceholder: "owner/slug, directory, Git URL, or npm package",
docs: "Docs",
install: "Install plugin",
installing: "Installing…",
+68 -4
View File
@@ -387,6 +387,9 @@ export const es: TranslationResources = {
completed: "Completada",
},
},
turnFooter: {
workedFor: "Trabajó durante {{duration}}",
},
compaction: {
loading: "Compactando...",
auto: "Contexto compactado automáticamente",
@@ -1003,6 +1006,47 @@ export const es: TranslationResources = {
actions: {
viewPullRequest: "Ver",
openOn: "Abrir en {{brand}}",
addToChat: "Añadir al chat",
addAllToChat: "Añadir todo al chat",
addingToChat: "Añadiendo...",
},
checksOverview: {
headline: {
actionRequired: "Algunas comprobaciones requieren tu atención",
failure: "Algunas comprobaciones no se superaron",
pending: "Algunas comprobaciones aún no han terminado",
success: "Todas las comprobaciones se superaron",
none: "Sin comprobaciones",
},
count: {
actionRequired: "{{count}} con acción pendiente",
warning: "{{count}} con advertencia",
failure: "{{count}} con error",
pending: "{{count}} en curso",
manual: "{{count}} manual(es)",
success: "{{count}} superada(s)",
ignored: "{{count}} omitida(s)",
},
detailOne: "Comprobación: {{parts}}",
detailMany: "Comprobaciones: {{parts}}",
groupOne: {
actionRequired: "{{count}} comprobación con acción pendiente",
warning: "{{count}} comprobación con advertencia",
failure: "{{count}} comprobación con error",
pending: "{{count}} comprobación en curso",
manual: "{{count}} comprobación manual",
success: "{{count}} comprobación superada",
ignored: "{{count}} comprobación omitida",
},
groupMany: {
actionRequired: "{{count}} comprobaciones con acción pendiente",
warning: "{{count}} comprobaciones con advertencia",
failure: "{{count}} comprobaciones con error",
pending: "{{count}} comprobaciones en curso",
manual: "{{count}} comprobaciones manuales",
success: "{{count}} comprobaciones superadas",
ignored: "{{count}} comprobaciones omitidas",
},
},
checksSummary: {
passedLabel: "pasó",
@@ -1016,17 +1060,21 @@ export const es: TranslationResources = {
checks: "cheques",
pipeline: "Pipeline",
reviews: "Reseñas",
activity: "Actividad",
},
empty: {
noJobs: "Sin trabajos",
loadingPipeline: "Cargando pipeline...",
pipelineJobsLoadFailed: "No se pudieron cargar los trabajos del pipeline",
allowedToFail: "permitido fallar",
noActivity: "Aún no hay actividad",
},
approvals: "{{given}} de {{required}} aprobaciones",
accessibility: {
pullRequest: "Solicitud de extracción n.°{{number}}",
pullRequest_mr: "Solicitud de fusión !{{number}}",
commentActions: "Acciones del comentario",
threadActions: "Acciones del hilo",
checkStatus: {
passed: "Superado",
failed: "Fallido",
@@ -1045,16 +1093,18 @@ export const es: TranslationResources = {
open: "Abierto",
},
activity: {
commented: "Comentado",
approved: "Aprobado",
requestedChanges: "Cambios solicitados",
reviewed: "Revisado",
commented: "Comentó",
approved: "Aprobó",
requestedChanges: "Solicitó cambios",
reviewed: "Revisó",
},
time: {
justNow: "En este momento",
},
thread: {
discussion: "Hilo de discusión",
resolved: "Resuelto",
outdated: "Desactualizado",
},
errors: {
statusLoadFailed: "No se puede cargar el estado de la solicitud de extracción",
@@ -1124,6 +1174,14 @@ export const es: TranslationResources = {
},
},
sidebar: {
statusGroupAccessibility: "Grupo {{label}}",
statusBucket: {
needsInput: "Necesita datos",
failed: "Con error",
readyToReview: "Para revisar",
working: "En ejecución",
done: "Terminado",
},
display: {
trigger: "Preferencias de visualización",
heading: "Visualización",
@@ -1749,6 +1807,12 @@ export const es: TranslationResources = {
helper: "Conéctate a un daemon de Paseo en el host remoto.",
fields: {
target: "Host SSH",
password: "Contraseña del daemon",
optional: "Opcional",
},
passwordVisibility: {
show: "Mostrar contraseña",
hide: "Ocultar contraseña",
},
actions: {
cancel: "Cancelar",
File diff suppressed because it is too large Load Diff
+64
View File
@@ -387,6 +387,9 @@ export const ja: TranslationResources = {
completed: "完了",
},
},
turnFooter: {
workedFor: "作業時間 {{duration}}",
},
compaction: {
loading: "コンテキストを圧縮中...",
auto: "コンテキストが自動的に圧縮されました",
@@ -983,6 +986,47 @@ export const ja: TranslationResources = {
actions: {
viewPullRequest: "表示",
openOn: "{{brand}}で開く",
addToChat: "チャットに追加",
addAllToChat: "すべてチャットに追加",
addingToChat: "追加中...",
},
checksOverview: {
headline: {
actionRequired: "対応が必要なチェックがあります",
failure: "失敗したチェックがあります",
pending: "完了していないチェックがあります",
success: "すべてのチェックに合格しました",
none: "チェックなし",
},
count: {
actionRequired: "{{count}} 件要対応",
warning: "{{count}} 件警告",
failure: "{{count}} 件失敗",
pending: "{{count}} 件実行中",
manual: "{{count}} 件手動",
success: "{{count}} 件成功",
ignored: "{{count}} 件スキップ",
},
detailOne: "チェック: {{parts}}",
detailMany: "チェック: {{parts}}",
groupOne: {
actionRequired: "要対応のチェック {{count}} 件",
warning: "警告のチェック {{count}} 件",
failure: "失敗したチェック {{count}} 件",
pending: "実行中のチェック {{count}} 件",
manual: "手動のチェック {{count}} 件",
success: "成功したチェック {{count}} 件",
ignored: "スキップされたチェック {{count}} 件",
},
groupMany: {
actionRequired: "要対応のチェック {{count}} 件",
warning: "警告のチェック {{count}} 件",
failure: "失敗したチェック {{count}} 件",
pending: "実行中のチェック {{count}} 件",
manual: "手動のチェック {{count}} 件",
success: "成功したチェック {{count}} 件",
ignored: "スキップされたチェック {{count}} 件",
},
},
checksSummary: {
passedLabel: "成功",
@@ -996,17 +1040,21 @@ export const ja: TranslationResources = {
checks: "チェック",
pipeline: "パイプライン",
reviews: "レビュー",
activity: "アクティビティ",
},
empty: {
noJobs: "ジョブなし",
loadingPipeline: "パイプラインを読み込み中...",
pipelineJobsLoadFailed: "パイプラインのジョブを読み込めませんでした",
allowedToFail: "失敗を許可",
noActivity: "まだアクティビティはありません",
},
approvals: "{{given}} / {{required}} 承認",
accessibility: {
pullRequest: "プルリクエスト#{{number}}",
pullRequest_mr: "マージリクエスト !{{number}}",
commentActions: "コメントの操作",
threadActions: "スレッドの操作",
checkStatus: {
passed: "成功",
failed: "失敗",
@@ -1035,6 +1083,8 @@ export const ja: TranslationResources = {
},
thread: {
discussion: "ディスカッションスレッド",
resolved: "解決済み",
outdated: "古い",
},
errors: {
statusLoadFailed: "プルリクエストのステータスを読み込めません",
@@ -1101,6 +1151,14 @@ export const ja: TranslationResources = {
},
},
sidebar: {
statusGroupAccessibility: "{{label}} グループ",
statusBucket: {
needsInput: "入力待ち",
failed: "失敗",
readyToReview: "レビュー待ち",
working: "実行中",
done: "完了",
},
display: {
trigger: "表示設定",
heading: "表示",
@@ -1719,6 +1777,12 @@ export const ja: TranslationResources = {
helper: "リモートホストで動作する Paseo デーモンに接続します。",
fields: {
target: "SSH ホスト",
password: "デーモンのパスワード",
optional: "任意",
},
passwordVisibility: {
show: "パスワードを表示",
hide: "パスワードを非表示",
},
actions: {
cancel: "キャンセル",
+64
View File
@@ -383,6 +383,9 @@ export const ko: TranslationResources = {
completed: "완료됨",
},
},
turnFooter: {
workedFor: "작업 시간 {{duration}}",
},
compaction: {
loading: "압축하는 중...",
auto: "컨텍스트가 자동으로 압축되었습니다",
@@ -977,6 +980,47 @@ export const ko: TranslationResources = {
actions: {
viewPullRequest: "보기",
openOn: "{{brand}}에서 열기",
addToChat: "채팅에 추가",
addAllToChat: "모두 채팅에 추가",
addingToChat: "추가 중...",
},
checksOverview: {
headline: {
actionRequired: "확인이 필요한 검사가 있습니다",
failure: "실패한 검사가 있습니다",
pending: "아직 완료되지 않은 검사가 있습니다",
success: "모든 검사를 통과했습니다",
none: "검사 없음",
},
count: {
actionRequired: "{{count}}개 조치 필요",
warning: "{{count}}개 경고",
failure: "{{count}}개 실패",
pending: "{{count}}개 진행 중",
manual: "{{count}}개 수동",
success: "{{count}}개 성공",
ignored: "{{count}}개 건너뜀",
},
detailOne: "검사: {{parts}}",
detailMany: "검사: {{parts}}",
groupOne: {
actionRequired: "조치 필요 검사 {{count}}개",
warning: "경고 검사 {{count}}개",
failure: "실패한 검사 {{count}}개",
pending: "진행 중인 검사 {{count}}개",
manual: "수동 검사 {{count}}개",
success: "성공한 검사 {{count}}개",
ignored: "건너뛴 검사 {{count}}개",
},
groupMany: {
actionRequired: "조치 필요 검사 {{count}}개",
warning: "경고 검사 {{count}}개",
failure: "실패한 검사 {{count}}개",
pending: "진행 중인 검사 {{count}}개",
manual: "수동 검사 {{count}}개",
success: "성공한 검사 {{count}}개",
ignored: "건너뛴 검사 {{count}}개",
},
},
checksSummary: {
passedLabel: "통과",
@@ -990,17 +1034,21 @@ export const ko: TranslationResources = {
checks: "검사",
pipeline: "파이프라인",
reviews: "리뷰",
activity: "활동",
},
empty: {
noJobs: "작업 없음",
loadingPipeline: "파이프라인 로드 중…",
pipelineJobsLoadFailed: "파이프라인 작업을 로드할 수 없습니다.",
allowedToFail: "실패가 허용됨",
noActivity: "아직 활동이 없습니다",
},
approvals: "{{required}} 중 {{given}} 승인",
accessibility: {
pullRequest: "풀 리퀘스트 #{{number}}",
pullRequest_mr: "병합 요청 !{{number}}",
commentActions: "댓글 작업",
threadActions: "스레드 작업",
checkStatus: {
passed: "통과",
failed: "실패",
@@ -1029,6 +1077,8 @@ export const ko: TranslationResources = {
},
thread: {
discussion: "토론 스레드",
resolved: "해결됨",
outdated: "오래됨",
},
errors: {
statusLoadFailed: "풀 리퀘스트 상태를 불러올 수 없습니다",
@@ -1095,6 +1145,14 @@ export const ko: TranslationResources = {
},
},
sidebar: {
statusGroupAccessibility: "{{label}} 그룹",
statusBucket: {
needsInput: "입력 필요",
failed: "실패",
readyToReview: "검토 대기",
working: "실행 중",
done: "완료",
},
display: {
trigger: "표시 설정",
heading: "표시",
@@ -1711,6 +1769,12 @@ export const ko: TranslationResources = {
helper: "원격 호스트에서 실행 중인 Paseo 데몬에 연결합니다.",
fields: {
target: "SSH 호스트",
password: "데몬 비밀번호",
optional: "선택 사항",
},
passwordVisibility: {
show: "비밀번호 표시",
hide: "비밀번호 숨기기",
},
actions: {
cancel: "취소",
@@ -27,7 +27,7 @@ export const pluginSettings = {
globalTitle: "تفعيل الإضافات",
globalHint: "مفتاح عام لكل الإضافات المضبوطة",
sourceLabel: "مصدر الإضافة",
sourcePlaceholder: "مجلد أو رابط Git أو حزمة npm",
sourcePlaceholder: "owner/slug, مجلد أو رابط Git أو حزمة npm",
docs: "الوثائق",
install: "تثبيت الإضافة",
installing: "جارٍ التثبيت…",
@@ -82,7 +82,7 @@ export const pluginSettings = {
globalTitle: "Activar plugins",
globalHint: "Interruptor global para todos los plugins configurados",
sourceLabel: "Fuente del plugin",
sourcePlaceholder: "Directorio, URL de Git o paquete npm",
sourcePlaceholder: "owner/slug, Directorio, URL de Git o paquete npm",
docs: "Documentación",
install: "Instalar plugin",
installing: "Instalando…",
@@ -137,7 +137,7 @@ export const pluginSettings = {
globalTitle: "Activer les plugins",
globalHint: "Interrupteur global pour tous les plugins configurés",
sourceLabel: "Source du plugin",
sourcePlaceholder: "Dossier, URL Git ou paquet npm",
sourcePlaceholder: "owner/slug, Dossier, URL Git ou paquet npm",
docs: "Documentation",
install: "Installer le plugin",
installing: "Installation…",
@@ -163,7 +163,7 @@ export const pluginSettings = {
globalEnabled: "Plugins activés",
globalDisabled: "Plugins désactivés",
},
removeConfirmTitle: "Supprimer {{id}} ?",
removeConfirmTitle: "Supprimer {{id}} ?",
removeConfirmMessage: "La configuration sera supprimée. Le dossier source ne sera pas effacé.",
states: {
offlineTitle: "L’hôte des plugins est hors ligne",
@@ -192,7 +192,7 @@ export const pluginSettings = {
globalTitle: "プラグインを有効化",
globalHint: "設定済みプラグインすべての共通スイッチ",
sourceLabel: "プラグインソース",
sourcePlaceholder: "ディレクトリ、Git URL、npm パッケージ",
sourcePlaceholder: "owner/slug, ディレクトリ、Git URL、npm パッケージ",
docs: "ドキュメント",
install: "プラグインをインストール",
installing: "インストール中…",
@@ -246,7 +246,7 @@ export const pluginSettings = {
globalTitle: "플러그인 활성화",
globalHint: "설정된 모든 플러그인의 전역 스위치",
sourceLabel: "플러그인 소스",
sourcePlaceholder: "디렉터리, Git URL 또는 npm 패키지",
sourcePlaceholder: "owner/slug, 디렉터리, Git URL 또는 npm 패키지",
docs: "문서",
install: "플러그인 설치",
installing: "설치 중…",
@@ -301,7 +301,7 @@ export const pluginSettings = {
globalTitle: "Ativar plugins",
globalHint: "Controle global para todos os plugins configurados",
sourceLabel: "Fonte do plugin",
sourcePlaceholder: "Diretório, URL Git ou pacote npm",
sourcePlaceholder: "owner/slug, Diretório, URL Git ou pacote npm",
docs: "Documentação",
install: "Instalar plugin",
installing: "Instalando…",
@@ -356,7 +356,7 @@ export const pluginSettings = {
globalTitle: "Включить плагины",
globalHint: "Общий переключатель для всех настроенных плагинов",
sourceLabel: "Источник плагина",
sourcePlaceholder: "Каталог, URL Git или пакет npm",
sourcePlaceholder: "owner/slug, Каталог, URL Git или пакет npm",
docs: "Документация",
install: "Установить плагин",
installing: "Установка…",
@@ -410,7 +410,7 @@ export const pluginSettings = {
globalTitle: "启用插件",
globalHint: "所有已配置插件的全局开关",
sourceLabel: "插件来源",
sourcePlaceholder: "目录、Git URL 或 npm 包",
sourcePlaceholder: "owner/slug, 目录、Git URL 或 npm 包",
docs: "文档",
install: "安装插件",
installing: "正在安装…",
+66 -2
View File
@@ -386,6 +386,9 @@ export const ptBR: TranslationResources = {
completed: "Concluída",
},
},
turnFooter: {
workedFor: "Trabalhou por {{duration}}",
},
compaction: {
loading: "Compactando...",
auto: "Contexto compactado automaticamente",
@@ -993,6 +996,47 @@ export const ptBR: TranslationResources = {
actions: {
viewPullRequest: "Ver",
openOn: "Abrir no {{brand}}",
addToChat: "Adicionar ao chat",
addAllToChat: "Adicionar tudo ao chat",
addingToChat: "Adicionando...",
},
checksOverview: {
headline: {
actionRequired: "Algumas verificações precisam da sua atenção",
failure: "Algumas verificações não foram bem-sucedidas",
pending: "Algumas verificações ainda não terminaram",
success: "Todas as verificações foram aprovadas",
none: "Nenhuma verificação",
},
count: {
actionRequired: "{{count}} com ação pendente",
warning: "{{count}} com aviso",
failure: "{{count}} com falha",
pending: "{{count}} em andamento",
manual: "{{count}} manual(is)",
success: "{{count}} aprovada(s)",
ignored: "{{count}} ignorada(s)",
},
detailOne: "Verificação: {{parts}}",
detailMany: "Verificações: {{parts}}",
groupOne: {
actionRequired: "{{count}} verificação com ação pendente",
warning: "{{count}} verificação com aviso",
failure: "{{count}} verificação com falha",
pending: "{{count}} verificação em andamento",
manual: "{{count}} verificação manual",
success: "{{count}} verificação aprovada",
ignored: "{{count}} verificação ignorada",
},
groupMany: {
actionRequired: "{{count}} verificações com ação pendente",
warning: "{{count}} verificações com aviso",
failure: "{{count}} verificações com falha",
pending: "{{count}} verificações em andamento",
manual: "{{count}} verificações manuais",
success: "{{count}} verificações aprovadas",
ignored: "{{count}} verificações ignoradas",
},
},
checksSummary: {
passedLabel: "passou",
@@ -1006,17 +1050,21 @@ export const ptBR: TranslationResources = {
checks: "Verificações",
pipeline: "Pipeline",
reviews: "Revisões",
activity: "Atividade",
},
empty: {
noJobs: "Sem jobs",
loadingPipeline: "Carregando pipeline...",
pipelineJobsLoadFailed: "Não foi possível carregar os jobs do pipeline",
allowedToFail: "permitido falhar",
noActivity: "Nenhuma atividade ainda",
},
approvals: "{{given}} de {{required}} aprovações",
accessibility: {
pullRequest: "Pull request #{{number}}",
pullRequest_mr: "Merge request !{{number}}",
commentActions: "Ações do comentário",
threadActions: "Ações da conversa",
checkStatus: {
passed: "Aprovado",
failed: "Falhou",
@@ -1036,15 +1084,17 @@ export const ptBR: TranslationResources = {
},
activity: {
commented: "Comentou",
approved: "Aprovado",
approved: "Aprovou",
requestedChanges: "Solicitou alterações",
reviewed: "Revisado",
reviewed: "Revisou",
},
time: {
justNow: "agora mesmo",
},
thread: {
discussion: "Tópico de discussão",
resolved: "Resolvido",
outdated: "Desatualizado",
},
errors: {
statusLoadFailed: "Não foi possível carregar o status da pull request",
@@ -1114,6 +1164,14 @@ export const ptBR: TranslationResources = {
},
},
sidebar: {
statusGroupAccessibility: "Grupo {{label}}",
statusBucket: {
needsInput: "Precisa de resposta",
failed: "Com falha",
readyToReview: "Para revisar",
working: "Em execução",
done: "Concluído",
},
display: {
trigger: "Preferências de exibição",
heading: "Exibição",
@@ -1734,6 +1792,12 @@ export const ptBR: TranslationResources = {
helper: "Conecte-se a um daemon Paseo no host remoto.",
fields: {
target: "Host SSH",
password: "Senha do daemon",
optional: "Opcional",
},
passwordVisibility: {
show: "Mostrar senha",
hide: "Ocultar senha",
},
actions: {
cancel: "Cancelar",
+64
View File
@@ -386,6 +386,9 @@ export const ru: TranslationResources = {
completed: "Завершена",
},
},
turnFooter: {
workedFor: "Время работы: {{duration}}",
},
compaction: {
loading: "Сжатие контекста...",
auto: "Контекст сжат автоматически",
@@ -987,6 +990,47 @@ export const ru: TranslationResources = {
actions: {
viewPullRequest: "Просмотреть",
openOn: "Открыть на {{brand}}",
addToChat: "Добавить в чат",
addAllToChat: "Добавить всё в чат",
addingToChat: "Добавление...",
},
checksOverview: {
headline: {
actionRequired: "Некоторые проверки требуют внимания",
failure: "Некоторые проверки не пройдены",
pending: "Некоторые проверки ещё не завершены",
success: "Все проверки пройдены",
none: "Нет проверок",
},
count: {
actionRequired: "требует действий: {{count}}",
warning: "с предупреждением: {{count}}",
failure: "не пройдено: {{count}}",
pending: "выполняется: {{count}}",
manual: "вручную: {{count}}",
success: "пройдено: {{count}}",
ignored: "пропущено: {{count}}",
},
detailOne: "Проверка ({{parts}})",
detailMany: "Проверки ({{parts}})",
groupOne: {
actionRequired: "Требует действий: {{count}}",
warning: "С предупреждением: {{count}}",
failure: "Не пройдена: {{count}}",
pending: "Выполняется: {{count}}",
manual: "Ручная: {{count}}",
success: "Пройдена: {{count}}",
ignored: "Пропущена: {{count}}",
},
groupMany: {
actionRequired: "Требуют действий: {{count}}",
warning: "С предупреждением: {{count}}",
failure: "Не пройдены: {{count}}",
pending: "Выполняются: {{count}}",
manual: "Ручные: {{count}}",
success: "Пройдены: {{count}}",
ignored: "Пропущены: {{count}}",
},
},
checksSummary: {
passedLabel: "успешно",
@@ -1000,17 +1044,21 @@ export const ru: TranslationResources = {
checks: "Проверки",
pipeline: "Пайплайн",
reviews: "Ревью",
activity: "Активность",
},
empty: {
noJobs: "Нет заданий",
loadingPipeline: "Загрузка пайплайна...",
pipelineJobsLoadFailed: "Не удалось загрузить задания пайплайна",
allowedToFail: "допускается сбой",
noActivity: "Активности пока нет",
},
approvals: "Одобрено: {{given}} из {{required}}",
accessibility: {
pullRequest: "PR #{{number}}",
pullRequest_mr: "MR !{{number}}",
commentActions: "Действия с комментарием",
threadActions: "Действия с обсуждением",
checkStatus: {
passed: "Успешно",
failed: "Ошибка",
@@ -1039,6 +1087,8 @@ export const ru: TranslationResources = {
},
thread: {
discussion: "Ветка обсуждения",
resolved: "Решено",
outdated: "Устарело",
},
errors: {
statusLoadFailed: "Не удалось загрузить статус PR",
@@ -1105,6 +1155,14 @@ export const ru: TranslationResources = {
},
},
sidebar: {
statusGroupAccessibility: "Группа «{{label}}»",
statusBucket: {
needsInput: "Ожидает ввода",
failed: "Ошибка",
readyToReview: "На проверку",
working: "В работе",
done: "Готово",
},
display: {
trigger: "Настройки отображения",
heading: "Отображение",
@@ -1732,6 +1790,12 @@ export const ru: TranslationResources = {
helper: "Подключитесь к демону Paseo на удалённом хосте.",
fields: {
target: "Хост SSH",
password: "Пароль демона",
optional: "Необязательно",
},
passwordVisibility: {
show: "Показать пароль",
hide: "Скрыть пароль",
},
actions: {
cancel: "Отмена",
+68 -4
View File
@@ -382,6 +382,9 @@ export const zhCN: TranslationResources = {
completed: "已完成",
},
},
turnFooter: {
workedFor: "工作了 {{duration}}",
},
compaction: {
loading: "正在压缩...",
auto: "上下文已自动压缩",
@@ -962,6 +965,47 @@ export const zhCN: TranslationResources = {
actions: {
viewPullRequest: "查看",
openOn: "在 {{brand}} 上打开",
addToChat: "添加到聊天",
addAllToChat: "全部添加到聊天",
addingToChat: "正在添加...",
},
checksOverview: {
headline: {
actionRequired: "部分检查需要你处理",
failure: "部分检查未通过",
pending: "部分检查尚未完成",
success: "所有检查均已通过",
none: "没有检查",
},
count: {
actionRequired: "{{count}} 个需处理",
warning: "{{count}} 个警告",
failure: "{{count}} 个失败",
pending: "{{count}} 个进行中",
manual: "{{count}} 个手动",
success: "{{count}} 个成功",
ignored: "{{count}} 个已跳过",
},
detailOne: "检查:{{parts}}",
detailMany: "检查:{{parts}}",
groupOne: {
actionRequired: "{{count}} 个需处理的检查",
warning: "{{count}} 个有警告的检查",
failure: "{{count}} 个失败的检查",
pending: "{{count}} 个进行中的检查",
manual: "{{count}} 个手动检查",
success: "{{count}} 个成功的检查",
ignored: "{{count}} 个已跳过的检查",
},
groupMany: {
actionRequired: "{{count}} 个需处理的检查",
warning: "{{count}} 个有警告的检查",
failure: "{{count}} 个失败的检查",
pending: "{{count}} 个进行中的检查",
manual: "{{count}} 个手动检查",
success: "{{count}} 个成功的检查",
ignored: "{{count}} 个已跳过的检查",
},
},
checksSummary: {
passedLabel: "通过",
@@ -975,17 +1019,21 @@ export const zhCN: TranslationResources = {
checks: "Checks",
pipeline: "流水线",
reviews: "Reviews",
activity: "动态",
},
empty: {
noJobs: "无作业",
loadingPipeline: "正在加载流水线...",
pipelineJobsLoadFailed: "无法加载流水线作业",
allowedToFail: "允许失败",
noActivity: "暂无动态",
},
approvals: "{{given}} / {{required}} 批准",
accessibility: {
pullRequest: "Pull request #{{number}}",
pullRequest_mr: "合并请求 !{{number}}",
commentActions: "评论操作",
threadActions: "讨论串操作",
checkStatus: {
passed: "成功",
failed: "失败",
@@ -998,22 +1046,24 @@ export const zhCN: TranslationResources = {
},
},
states: {
draft: "Draft",
merged: "已 merge",
draft: "草稿",
merged: "已合并",
closed: "已关闭",
open: "Open",
open: "开放",
},
activity: {
commented: "已评论",
approved: "已批准",
requestedChanges: "请求修改",
reviewed: "已 review",
reviewed: "已审查",
},
time: {
justNow: "刚刚",
},
thread: {
discussion: "讨论主题",
resolved: "已解决",
outdated: "已过时",
},
errors: {
statusLoadFailed: "无法加载 Pull Request 状态",
@@ -1080,6 +1130,14 @@ export const zhCN: TranslationResources = {
},
},
sidebar: {
statusGroupAccessibility: "{{label}} 分组",
statusBucket: {
needsInput: "需要输入",
failed: "失败",
readyToReview: "待查看",
working: "运行中",
done: "已完成",
},
display: {
trigger: "显示偏好",
heading: "显示",
@@ -1683,6 +1741,12 @@ export const zhCN: TranslationResources = {
helper: "连接到远程主机上运行的 Paseo 守护进程。",
fields: {
target: "SSH 主机",
password: "守护进程密码",
optional: "可选",
},
passwordVisibility: {
show: "显示密码",
hide: "隐藏密码",
},
actions: {
cancel: "取消",
@@ -6,17 +6,20 @@ import { useKeyboardShift } from "./context";
interface KeyboardTranslateViewProps extends ViewProps {
children: ReactNode;
enabled?: boolean;
/** Space below the view's resting position that the keyboard may fill before the view moves. */
bottomClearance?: number;
}
export function KeyboardTranslateView({
children,
enabled = true,
bottomClearance = 0,
style,
...props
}: KeyboardTranslateViewProps) {
const { shift } = useKeyboardShift();
const keyboardStyle = useAnimatedStyle(() => ({
transform: [{ translateY: enabled ? -shift.value : 0 }],
transform: [{ translateY: enabled ? -Math.max(0, shift.value - bottomClearance) : 0 }],
}));
return (
@@ -6,19 +6,31 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
interface KeyboardTranslateViewProps extends ViewProps {
children: ReactNode;
enabled?: boolean;
/** Space below the view's resting position that the keyboard may fill before the view moves. */
bottomClearance?: number;
}
export function KeyboardTranslateView({
children,
enabled = true,
bottomClearance = 0,
style,
...props
}: KeyboardTranslateViewProps) {
const insets = useSafeAreaInsets();
const { height, progress } = useKeyboardAnimation();
const translateY = useMemo(
() => Animated.add(height, Animated.multiply(progress, insets.bottom)),
[height, insets.bottom, progress],
() =>
Animated.add(
Animated.add(height, Animated.multiply(progress, insets.bottom)),
bottomClearance,
).interpolate({
inputRange: [-1, 0],
outputRange: [-1, 0],
extrapolateLeft: "extend",
extrapolateRight: "clamp",
}),
[bottomClearance, height, insets.bottom, progress],
);
const keyboardStyle = useMemo(
() => ({ transform: [{ translateY: enabled ? translateY : 0 }] }),
@@ -4,11 +4,13 @@ import { View, type ViewProps } from "react-native";
interface KeyboardTranslateViewProps extends ViewProps {
children: ReactNode;
enabled?: boolean;
bottomClearance?: number;
}
export function KeyboardTranslateView({
children,
enabled: _enabled,
bottomClearance: _bottomClearance,
...props
}: KeyboardTranslateViewProps) {
return <View {...props}>{children}</View>;
@@ -3919,6 +3919,53 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
it("authenticates Remote SSH probes with the entered daemon password and stores it verbatim", async () => {
const store = createPairingStore({
password: " s3cret ",
serverIdForHost: (host) => (host.connections[0]?.type === "remoteSsh" ? "srv_ssh" : ""),
});
answerHostConfirmations(store, false);
await store.boot();
await store.probeAndUpsertRemoteSshConnection({
host: "deploy@example.com",
password: " s3cret ",
});
const host = store.getHosts().find((entry) => entry.serverId === "srv_ssh");
expect(host?.password).toBe(" s3cret ");
expect(host?.connections[0]).toMatchObject({
type: "remoteSsh",
host: "deploy@example.com",
});
// The fix promises the saved password is sent on every connection: a later
// cycle must authenticate from the stored profile alone.
await store.runProbeCycleNow("srv_ssh");
await waitForHostOnline(store, "srv_ssh");
store.syncHosts([]);
});
it("rejects Remote SSH probes without the daemon password, the way the daemon does", async () => {
const store = createPairingStore({
password: "s3cret",
serverIdForHost: (host) => (host.connections[0]?.type === "remoteSsh" ? "srv_ssh" : ""),
});
answerHostConfirmations(store, false);
await store.boot();
const failure = await store
.probeAndUpsertRemoteSshConnection({ host: "deploy@example.com" })
.then(
() => null,
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(DaemonAuthenticationError);
expect((failure as DaemonAuthenticationError).reason).toBe("password_required");
expect(store.getHosts().find((entry) => entry.serverId === "srv_ssh")).toBeUndefined();
store.syncHosts([]);
});
it("preserves the existing host label when re-pairing an existing relay host", async () => {
const store = createPairingStore({ hostname: "mbp" });
answerHostConfirmations(store, true);
+5
View File
@@ -1916,10 +1916,14 @@ export class HostRuntimeStore {
host: string;
sshPort?: number;
daemonPort?: number;
password?: string;
label?: string;
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
return this.probeAndUpsertConnection({
label: input.label,
// The daemon hashes and compares the password verbatim (`paseo daemon
// set-password` keeps what it reads), so whitespace is significant here.
password: input.password ? input.password : undefined,
connection: createRemoteSshHostConnection(input),
});
}
@@ -2844,6 +2848,7 @@ export interface HostMutations {
host: string;
sshPort?: number;
daemonPort?: number;
password?: string;
label?: string;
}) => Promise<{ profile: HostProfile; serverId: string; hostname: string | null }>;
beginLinkPairing: () => LinkPairing;
+9 -1
View File
@@ -27,7 +27,12 @@ export function AgentUsage({
<>
<View style={styles.divider} />
{view.kind === "ready" ? (
<AgentUsageCards serverId={serverId} reports={view.reports} refreshable={refreshable} />
<AgentUsageCards
serverId={serverId}
agentId={agentId}
reports={view.reports}
refreshable={refreshable}
/>
) : (
<Text style={styles.message}>
{view.kind === "loading" ? usageCopy.loading : view.message}
@@ -39,10 +44,12 @@ export function AgentUsage({
function AgentUsageCards({
serverId,
agentId,
reports,
refreshable,
}: {
serverId: string;
agentId: string;
reports: readonly UsageReportEntry[];
refreshable: boolean;
}) {
@@ -51,6 +58,7 @@ function AgentUsageCards({
<UsageCard
key={entry.id}
serverId={serverId}
agentId={agentId}
entry={entry}
display={display}
compact
+20 -5
View File
@@ -40,11 +40,24 @@ function reportContent(report: UsageReport) {
return { windows: [], balances: [], details: [], message };
}
function reportMessages(entry: UsageReportEntry): string[] {
if (entry.report.status === "available") return [];
if (entry.loginErrors)
return entry.loginErrors.map(
(login) => `${login.harness}: ${reportContent(login.report).message}`,
);
// COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0.
return [
entry.report.status === "error" ? entry.report.error : usageCopy.problem(entry.report.problem),
];
}
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
export function UsageCard({
serverId,
agentId,
entry,
display,
pinnable,
@@ -52,6 +65,7 @@ export function UsageCard({
compact = false,
}: {
serverId: string;
agentId?: string;
entry: UsageReportEntry;
display: UsageDisplay;
/** Whether each window row pins the window to the sidebar. */
@@ -61,13 +75,14 @@ export function UsageCard({
compact?: boolean;
}) {
const isCompact = useIsCompactFormFactor();
const { refresh, refreshState } = useReportRefresh(serverId, entry.id);
const { refresh, refreshState } = useReportRefresh(serverId, entry.id, agentId);
// Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip.
const showsFreshnessInline = isNative || isCompact || !refreshable;
const usage = entry.report;
const status = statusText(usage);
const footer = entry.account.label ?? null;
const { windows, balances, details, message } = reportContent(usage);
const { windows, balances, details } = reportContent(usage);
const messages = reportMessages(entry);
const containerStyle = useMemo(
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
@@ -110,9 +125,9 @@ export function UsageCard({
) : null}
</View>
{message ? (
<Text style={styles.error} numberOfLines={3}>
{message}
{messages.length > 0 ? (
<Text style={styles.error} testID="usage-login-error">
{messages.join("\n")}
</Text>
) : null}
+1 -2
View File
@@ -65,8 +65,7 @@ const styles = StyleSheet.create((theme) => ({
}));
/**
* One host's usage and the title-row controls that go with it, for the Usage screen and the
* compact usage sheet.
* One host's usage and the title-row controls that go with it, for the Usage modal.
*/
export function useHostUsageWithControls(hostSelection: UsageHostSelection): {
view: UsageView;
+3 -3
View File
@@ -6,7 +6,7 @@ import {
} from "@/stores/navigation-active-workspace-store";
import {
resolveUsageHostId,
resolveUsageScreenHostId,
resolveUsageModalHostId,
type UsageHost,
type UsageHostChoice,
} from "./model";
@@ -35,7 +35,7 @@ export function useUsageHostId(): string | null {
}
/**
* The host the Usage screen or the compact usage sheet shows, and the hosts to pick from. A pick
* The host the Usage modal shows, and the hosts to pick from. A pick
* is saved on the device, so the sidebar Usage row and later visits show the same host.
*/
export function useUsageHostSelection(): {
@@ -55,5 +55,5 @@ export function useUsageHostSelection(): {
},
[updateSettings],
);
return { serverId: resolveUsageScreenHostId(choice), connectedHosts, select };
return { serverId: resolveUsageModalHostId(choice), connectedHosts, select };
}
-1
View File
@@ -1,7 +1,6 @@
export { AgentUsage } from "./agent-usage";
export { HostUsageSection } from "./host-usage-section";
export { useHostReportsUsage } from "./queries";
export { UsageScreen } from "./usage-screen";
export {
UsageSidebarItem,
UsageSidebarRoot,
+10 -10
View File
@@ -6,7 +6,7 @@ import {
replaceReport,
resolveUsageRefresh,
resolveUsageHostId,
resolveUsageScreenHostId,
resolveUsageModalHostId,
resolveUsageView,
settleReports,
upsertReport,
@@ -117,35 +117,35 @@ describe("resolveUsageHostId", () => {
});
});
describe("resolveUsageScreenHostId", () => {
describe("resolveUsageModalHostId", () => {
it("shows the picked host while it stays connected, even one that cannot report usage", () => {
expect(resolveUsageScreenHostId({ pickedServerId: "old", activeServerId: "b", hosts })).toBe(
expect(resolveUsageModalHostId({ pickedServerId: "old", activeServerId: "b", hosts })).toBe(
"old",
);
expect(
resolveUsageScreenHostId({ pickedServerId: "offline", activeServerId: "b", hosts }),
).toBe("b");
expect(resolveUsageModalHostId({ pickedServerId: "offline", activeServerId: "b", hosts })).toBe(
"b",
);
});
it("otherwise shows the host the sidebar row reads", () => {
for (const pickedServerId of [null, "a", "offline"]) {
for (const activeServerId of ["b", null, "old", "offline"]) {
const choice = { pickedServerId, activeServerId, hosts };
expect(resolveUsageScreenHostId(choice)).toBe(resolveUsageHostId(choice));
expect(resolveUsageModalHostId(choice)).toBe(resolveUsageHostId(choice));
}
}
});
it("shows the first connected host when none reports usage, so the screen says to update it", () => {
it("shows the first connected host when none reports usage, so the modal says to update it", () => {
expect(
resolveUsageScreenHostId({
resolveUsageModalHostId({
pickedServerId: null,
activeServerId: null,
hosts: hosts.slice(0, 2),
}),
).toBe("old");
expect(
resolveUsageScreenHostId({ pickedServerId: null, activeServerId: null, hosts: [] }),
resolveUsageModalHostId({ pickedServerId: null, activeServerId: null, hosts: [] }),
).toBeNull();
});
});
+3 -3
View File
@@ -176,11 +176,11 @@ export function resolveUsageHostId(choice: UsageHostChoice): string | null {
}
/**
* The host the Usage screen shows: the picked host while it is connected, even one that cannot
* report usage so the screen says to update it; else the sidebar row's host; else the first
* The host the Usage modal shows: the picked host while it is connected, even one that cannot
* report usage so the modal says to update it; else the sidebar row's host; else the first
* connected host.
*/
export function resolveUsageScreenHostId(choice: UsageHostChoice): string | null {
export function resolveUsageModalHostId(choice: UsageHostChoice): string | null {
const connected = choice.hosts.filter((host) => host.isConnected);
const picked = connected.find((host) => host.serverId === choice.pickedServerId);
return picked?.serverId ?? resolveUsageHostId(choice) ?? connected[0]?.serverId ?? null;
+14 -8
View File
@@ -98,10 +98,14 @@ async function getReport(
serverId: string,
reportId: string,
forceRefresh = false,
agentId?: string,
): Promise<UsageReportEntry | null> {
return (
(await requireClient(serverId).listUsageReports({ reportIds: [reportId], forceRefresh }))
.reports[0] ?? null
(
await requireClient(serverId).listUsageReports(
agentId === undefined ? { reportIds: [reportId], forceRefresh } : { agentId, forceRefresh },
)
).reports.find((report) => report.id === reportId) ?? null
);
}
@@ -223,20 +227,22 @@ export function useUsageHosts(): UsageHost[] {
}
/**
* Forces the source to fetch one report, and only that report. The result replaces the report
* in every list of its host that holds it, the host's and each agent's, so every surface showing
* it moves together; until then the previous report stays on screen.
* Refreshes a host report, or the agent's session reports. The result replaces the card
* in the requesting view. Host and agent views can use different logins of the same account.
*/
export function useReportRefresh(
serverId: string,
reportId: string,
agentId?: string,
): { refresh: () => void; refreshState: UsageRefresh } {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: () => getReport(serverId, reportId, true),
mutationFn: () => getReport(serverId, reportId, true, agentId),
onSuccess: (report) => {
queryClient.setQueriesData<UsageReportEntry[]>(
{ queryKey: hostUsageQueryKey(serverId) },
queryClient.setQueryData<UsageReportEntry[]>(
agentId === undefined
? usageReportsQueryKey(serverId)
: agentUsageQueryKey(serverId, agentId),
(reports) => (reports ? replaceReport(reports, reportId, report) : reports),
);
},
+18 -84
View File
@@ -1,4 +1,3 @@
import { router } from "expo-router";
import {
createContext,
Fragment,
@@ -17,13 +16,9 @@ import {
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { SidebarPopoverRoot, SidebarPopoverSurface } from "@/components/sidebar/sidebar-popover";
import { useIsCompactFormFactor } from "@/constants/layout";
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
import { buildUsageRoute } from "@/utils/host-routes";
import { useHostUsageWithControls } from "./controls";
import { useUsagePreferences, type UsageDisplay } from "./display";
import { useUsageHostId, useUsageHostSelection } from "./hosts";
import { useUsagePreferences } from "./display";
import { useUsageHostId } from "./hosts";
import { useUsageHostReports } from "./queries";
import { UsageSourceIcon } from "./source-icon";
import { UsageMeter } from "./meter";
@@ -35,8 +30,7 @@ import {
type PinnedUsageLayout,
type PinnedUsageSource,
} from "./pinned";
import type { UsageHost } from "./model";
import { UsageBody } from "./usage-section";
import { UsageModal } from "./usage-modal";
/** Each summary window with data on the usage host, under its source; empty while none has. */
function useUsageSummary(): readonly PinnedUsageSource[] {
@@ -52,8 +46,8 @@ export function useHasUsageSummary(): boolean {
/**
* The sidebar footer's usage entry: each summary window's source icon and percent, and nothing
* while no summary window has data, since the footer's Usage icon already opens the screen.
* Pressing it opens the Usage screen; on compact layouts it opens the usage sheet instead.
* while no summary window has data, since the footer's Usage icon already opens Usage.
* Pressing it opens the Usage modal.
*/
export function UsageSidebarItem() {
const { t } = useTranslation();
@@ -66,30 +60,25 @@ export function UsageSidebarItem() {
const OpenSidebarUsageContext = createContext<(() => void) | null>(null);
/** One owner for the footer icon and summary: a compact sheet or the wide Usage screen. */
/**
* One owner for the footer icon and summary: both open the Usage modal over the current screen.
* The modal mounts on first open and unmounts once it has finished closing, so its host's reports
* load only while it is shown.
*/
export function UsageSidebarRoot({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const { display } = useUsagePreferences();
const isCompact = useIsCompactFormFactor();
const [open, setOpen] = useState(false);
const [sheetMounted, setSheetMounted] = useState(false);
const [mounted, setMounted] = useState(false);
const openUsage = useCallback(() => {
if (isCompact) {
setSheetMounted(true);
setOpen(true);
return;
}
router.push(buildUsageRoute());
}, [isCompact]);
setMounted(true);
setOpen(true);
}, []);
const closeUsage = useCallback(() => setOpen(false), []);
const unmountUsage = useCallback(() => setMounted(false), []);
return (
<OpenSidebarUsageContext.Provider value={openUsage}>
<SidebarPopoverRoot open={isCompact && open} onOpenChange={setOpen}>
{children}
{isCompact && sheetMounted ? (
<UsageSheet title={t(builtinSidebarNavLabelKey("usage"))} display={display} />
) : null}
</SidebarPopoverRoot>
{children}
{mounted ? <UsageModal visible={open} onClose={closeUsage} onDismiss={unmountUsage} /> : null}
</OpenSidebarUsageContext.Provider>
);
}
@@ -101,57 +90,6 @@ export function useOpenSidebarUsage(): () => void {
return openUsage;
}
/**
* The compact usage sheet: the Usage screen's host, reports with pins, and controls, the controls
* in its title row.
*/
function UsageSheet({ title, display }: { title: string; display: UsageDisplay }) {
const { serverId, connectedHosts, select } = useUsageHostSelection();
if (!serverId) return null;
return (
<HostUsageSheet
key={serverId}
title={title}
serverId={serverId}
hosts={connectedHosts}
onSelectHost={select}
display={display}
/>
);
}
function HostUsageSheet({
title,
serverId,
hosts,
onSelectHost,
display,
}: {
title: string;
serverId: string;
hosts: UsageHost[];
onSelectHost: (serverId: string) => void;
display: UsageDisplay;
}) {
const hostSelection = useMemo(
() => ({ hosts, serverId, onSelect: onSelectHost }),
[hosts, onSelectHost, serverId],
);
const { view, refresh, controls } = useHostUsageWithControls(hostSelection);
return (
<SidebarPopoverSurface
section="footer"
title={title}
sheetTrailing={controls}
testID="sidebar-usage-sheet"
>
<View style={styles.sheetBody} testID="usage-expanded">
<UsageBody serverId={serverId} view={view} display={display} onRefresh={refresh} />
</View>
</SidebarPopoverSurface>
);
}
function pinnedUsageLabel(label: string, sources: readonly PinnedUsageSource[]): string {
const windows = sources.flatMap((source) => source.windows);
return `${label}: ${windows.map((window) => window.label).join(", ")}`;
@@ -343,8 +281,4 @@ const styles = StyleSheet.create((theme) => ({
windowLabel: {
color: theme.colors.foregroundMuted,
},
sheetBody: {
padding: theme.spacing[3],
gap: theme.spacing[3],
},
}));
+93
View File
@@ -0,0 +1,93 @@
import { useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
import { useHostUsageWithControls } from "./controls";
import { usageCopy } from "./copy";
import { useUsagePreferences } from "./display";
import { useUsageHostSelection } from "./hosts";
import type { UsageHost } from "./model";
import { UsageBody, UsageMessage } from "./usage-section";
interface UsageModalProps {
visible: boolean;
onClose: () => void;
onDismiss: () => void;
}
/**
* Usage over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. It
* shows the usage host's reports with pins, and the host filter, Refresh all, and Settings in its
* title row.
*/
export function UsageModal(props: UsageModalProps) {
const { serverId, connectedHosts, select } = useUsageHostSelection();
if (!serverId) {
return (
<UsageModalFrame {...props}>
<UsageMessage text={usageCopy.noHosts} />
</UsageModalFrame>
);
}
// Keyed by host so a refresh in flight on one host never shows on another's cards.
return (
<HostUsageModal
key={serverId}
{...props}
serverId={serverId}
hosts={connectedHosts}
onSelectHost={select}
/>
);
}
function HostUsageModal({
serverId,
hosts,
onSelectHost,
...props
}: UsageModalProps & {
serverId: string;
hosts: UsageHost[];
onSelectHost: (serverId: string) => void;
}) {
const { display } = useUsagePreferences();
const hostSelection = useMemo(
() => ({ hosts, serverId, onSelect: onSelectHost }),
[hosts, onSelectHost, serverId],
);
const { view, refresh, controls } = useHostUsageWithControls(hostSelection);
return (
<UsageModalFrame {...props} actions={controls}>
<View testID={`usage-host-${serverId}`}>
<UsageBody serverId={serverId} view={view} display={display} onRefresh={refresh} />
</View>
</UsageModalFrame>
);
}
function UsageModalFrame({
visible,
onClose,
onDismiss,
actions,
children,
}: UsageModalProps & { actions?: ReactNode; children: ReactNode }) {
const { t } = useTranslation();
const title = t(builtinSidebarNavLabelKey("usage"));
const header = useMemo(() => ({ title, actions }), [actions, title]);
return (
<AdaptiveModalSheet
header={header}
visible={visible}
onClose={onClose}
onDismiss={onDismiss}
desktopMaxWidth={640}
contextBridge={null}
testID="usage-modal"
>
<View testID="usage-modal-body">{children}</View>
</AdaptiveModalSheet>
);
}
-72
View File
@@ -1,72 +0,0 @@
import { useIsFocused } from "@react-navigation/native";
import { router } from "expo-router";
import { useMemo, type ReactNode } from "react";
import { View } from "react-native";
import { PageLayout } from "@/components/page-layout";
import { useHostUsageWithControls } from "./controls";
import { usageCopy } from "./copy";
import { useUsagePreferences } from "./display";
import { useUsageHostSelection } from "./hosts";
import type { UsageHost } from "./model";
import { UsageBody, UsageMessage } from "./usage-section";
// The screen is reachable by URL, so there may be no history to go back to.
function leaveUsage(): void {
if (router.canGoBack()) {
router.back();
return;
}
router.replace("/");
}
export function UsageScreen() {
const isFocused = useIsFocused();
if (!isFocused) return <UsagePage>{null}</UsagePage>;
return <FocusedUsageScreen />;
}
function UsagePage({ actions, children }: { actions?: ReactNode; children: ReactNode }) {
return (
<PageLayout title={usageCopy.title} onBack={leaveUsage} actions={actions} testID="usage-screen">
{children}
</PageLayout>
);
}
function FocusedUsageScreen() {
const { serverId, connectedHosts, select } = useUsageHostSelection();
if (!serverId) {
return (
<UsagePage>
<UsageMessage text={usageCopy.noHosts} />
</UsagePage>
);
}
return (
<HostUsage key={serverId} serverId={serverId} hosts={connectedHosts} onSelectHost={select} />
);
}
function HostUsage({
serverId,
hosts,
onSelectHost,
}: {
serverId: string;
hosts: UsageHost[];
onSelectHost: (serverId: string) => void;
}) {
const { display } = useUsagePreferences();
const hostSelection = useMemo(
() => ({ hosts, serverId, onSelect: onSelectHost }),
[hosts, onSelectHost, serverId],
);
const { view, refresh, controls } = useHostUsageWithControls(hostSelection);
return (
<UsagePage actions={controls}>
<View testID={`usage-host-${serverId}`}>
<UsageBody serverId={serverId} view={view} display={display} onRefresh={refresh} />
</View>
</UsagePage>
);
}
-4
View File
@@ -428,10 +428,6 @@ export function buildSchedulesRoute() {
return "/schedules" as const;
}
export function buildUsageRoute() {
return "/usage" as const;
}
export function buildOpenProjectRoute() {
return "/open-project" as const;
}
@@ -1,3 +1,4 @@
import { i18n } from "@/i18n/i18next";
import { describe, expect, it } from "vitest";
import type {
SidebarProjectEntry,
@@ -211,6 +212,7 @@ describe("buildStatusSidebarShortcutModel", () => {
["p1", "Project 1"],
["p2", "Project 2"],
]),
i18n.t,
),
});
@@ -247,7 +249,7 @@ describe("buildStatusSidebarShortcutModel", () => {
];
const model = buildStatusSidebarShortcutModel({
groups: buildStatusGroups(workspaces, new Map([["p1", "Project 1"]])),
groups: buildStatusGroups(workspaces, new Map([["p1", "Project 1"]]), i18n.t),
collapsedStatusGroupKeys: new Set(["needs_input"]),
});
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.11.0-beta.4",
"version": "0.11.0-beta.5",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.11.0-beta.4",
"@getpaseo/protocol": "0.11.0-beta.4",
"@getpaseo/server": "0.11.0-beta.4",
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/server": "0.11.0-beta.5",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+5 -25
View File
@@ -6,6 +6,7 @@ export function addAttachOptions(cmd: Command): Command {
.argument("<id>", "Agent ID (or prefix)");
}
import { connectToDaemon } from "../../utils/client.js";
import { waitForStop } from "../../utils/wait-for-stop.js";
import {
fetchProjectedTimelineItems,
LIVE_HISTORY_FETCH_TIMEOUT_MS,
@@ -157,31 +158,10 @@ export async function runAttachCommand(
await unsubscribe.ready;
console.log(`Attached to agent ${resolvedId.substring(0, 7)}.`);
// Handle Ctrl+C to detach gracefully
let detached = false;
const detach = () => {
if (detached) return;
detached = true;
console.log("\n\nDetaching from agent...");
unsubscribe();
client
.close()
.then(() => {
process.exit(0);
})
.catch(() => {
process.exit(1);
});
};
process.on("SIGINT", detach);
process.on("SIGTERM", detach);
// Keep the process alive
await new Promise(() => {
// Wait indefinitely until interrupted
});
await waitForStop();
console.log("\n\nDetaching from agent...");
unsubscribe();
await client.close();
} catch (err) {
await client.close().catch(() => {});
const message = err instanceof Error ? err.message : String(err);
+3 -11
View File
@@ -1,5 +1,6 @@
import { Command } from "commander";
import { connectToDaemon } from "../../utils/client.js";
import { waitForStop } from "../../utils/wait-for-stop.js";
import type { CommandOptions } from "../../output/index.js";
import {
fetchProjectedTimelineItems,
@@ -224,16 +225,7 @@ async function runFollowMode(
await unsubscribe.ready;
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
// Wait for interrupt
await new Promise<void>((resolve) => {
const cleanup = () => {
unsubscribe();
resolve();
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
});
await waitForStop();
unsubscribe();
await client.close();
}
@@ -358,14 +358,21 @@ test.skipIf(process.platform === "win32").each([["start"], ["daemon", "run"]])(
stdio: "ignore",
});
const exited = new Promise((resolve) => child!.once("exit", resolve));
// The lock file exists before its contents identify the supervisor.
let supervisorPid: number | undefined;
await expect
.poll(async () => existsSync(path.join(home, "paseo.pid")), { timeout: 10_000 })
.toBe(true);
const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8"));
.poll(
async () => {
supervisorPid = (await readDaemonInstance(home))?.pid;
return supervisorPid;
},
{ timeout: 10_000 },
)
.toBeTypeOf("number");
await f.ok(["status", "--home", home]);
child.kill("SIGINT");
await exited;
expect(() => process.kill(lock.pid, 0)).toThrow();
expect(() => process.kill(supervisorPid!, 0)).toThrow();
expect((await f.ok(["status", "--home", home])).localDaemon).toBe("stopped");
} finally {
child?.kill("SIGTERM");
+6 -1
View File
@@ -58,7 +58,12 @@ async function probeDaemonStatus(
let live: Record<string, unknown> = {};
let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined;
try {
client = await connectToDaemon({ target, instance: instance ?? undefined, timeout: 1_500 });
// An explicit host may sit behind SSH or a relay, so it gets the usual connect budget.
client = await connectToDaemon({
target,
instance: instance ?? undefined,
timeout: target.kind === "endpoint" ? undefined : 1_500,
});
} catch (error) {
const failure = buildDaemonConnectionCommandError({ target, error });
if (target.kind === "endpoint") throw failure;
@@ -255,12 +255,12 @@ describe("plugin management commands", () => {
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const command = createPluginCommand();
await command.parseAsync(["install", "owner/monorepo", "--path", "plugins/review"], {
await command.parseAsync(["install", "git:owner/monorepo", "--path", "plugins/review"], {
from: "user",
});
expect(installPluginSource).toHaveBeenCalledWith({
source: "owner/monorepo:plugins/review",
source: "git:owner/monorepo:plugins/review",
});
stderr.mockRestore();
});

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