Compare commits

...
Author SHA1 Message Date
Mohamed Boudra e791ab3df7 test(ios): run the native text probe on the main thread 2026-10-09 22:41:11 +02:00
Mohamed BoudraandOxxx f521abfb24 fix(ios): keep recycled text views at their container width
Apply the minimal candidate from #6364 and cover recycled geometry in the native probe. Document that the probe does not reproduce mounted Fabric clipping.

Co-authored-by: Oxxx <103127477+omcdowell@users.noreply.github.com>
2026-10-09 22:24:28 +02:00
Mohamed Boudra b89ebbede6 fix(ios): restore native text reflow for evaluation 2026-10-09 22:24:28 +02:00
Mohamed Boudra aec01ea605 feat(claude): offer Mythos 5.1 when Claude Code advertises it (#6403)
* feat(claude): add Mythos 5.1 with its model capabilities

* feat(claude): gate Mythos on advertised model identity

* fix(claude): retain capabilities for explicitly configured Mythos

* test(usage): load SQLite before timed store tests

The first Windows OMP store case exceeded its five-second budget while subsequent equivalent cases completed under a second. Load the builtin during test collection so its cold initialization does not consume the first test deadline.

* test(workspaces): publish complete service environment fixtures

Waiting for file existence races with writeFileSync creating the file before writing its contents. Rename completed JSON snapshots into place so the existing readiness check cannot expose an empty file.
2026-10-09 21:05:40 +01:00
paseo-ai[bot] a1da1b8f23 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-09 15:43:26 +00:00
Mohamed Boudra 73390346ca fix(ios): address clipped chat and Markdown text (#6445)
* Revert "fix(app): reflow iPad chat text after pane resize (#5716)"

This reverts commit 5715b5ba62.

* docs(changelog): describe iOS text clipping fix for 0.11.2

* chore(release): cut 0.11.2
2026-10-09 16:35:43 +01:00
Mohamed Boudra 718ed932b7 Clarify maintainer and community roles in sponsor copy 2026-10-09 12:27:52 +02:00
0009a7e717 feat(workspaces): add background visibility (#5039)
* feat(server): let clients create internal agents

The daemon's own branch-name and commit-message generators run as
internal agents: never persisted, listed, flagged, announced, or seen by
plugin lifecycle hooks. Nothing outside the daemon could ask for one,
because the wire config schema strips `internal` and the agent.create
transform re-pins it. A plugin that runs a one-turn helper per event
therefore left an archived session in History every time.

Add an optional `internal` flag to `create_agent_request` (inherited by
`agent.create.request`), copy it onto the config in the session's create
path, and skip provider session persistence for it like the generators
do. The client SDK forwards the flag on both creation paths and gates it
once on `server_info.features.internalAgents`.

Two daemon paths assumed every agent is public and had to follow:
`resolveAgentIdentifier` now accepts a live agent by exact id, since the
id set it matched against came from listings that exclude internal
agents and left `wait_for_finish` answering "Agent not found"; and
archiving an internal agent now closes it without writing the record
that `registry.applySnapshot` used to leave behind, so `autoArchive`
does not accumulate hidden files on disk.

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

* fix(server): keep internal agents whole through failure, archive, and wait

Review follow-ups for internal agents:

- A create that fails after the runtime is registered now closes the
  internal agent before rethrowing, so a hidden runtime cannot outlive
  the failed request. Public agents keep today's behaviour.
- Archiving an internal agent cascades to attached children like any
  parent. cascadeArchiveChildren takes the parent record instead of
  reading it back from storage, where an internal agent never is.
- The archived snapshot and last message of an internal agent stay
  readable by exact id for ten minutes, so create-then-waitForFinish
  returns the finished turn whether the wait lands before or after an
  auto-archive. Storage plays that role for public agents.
- The internal-agent e2e scenarios live in the existing
  create_agent_request lifecycle suite rather than a new file.

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

* fix(server): cascade to live internal children and keep a failed internal create's worktree until it closes

Internal children never reach storage, so the parent archive cascade scans
live agents for them as well. A failed internal create now clears the
created id only after the runtime is archived, so worktree cleanup cannot
delete the directory under a runtime that would not close. Stored agent
payloads carry lastError, so a retained failed helper reports its error
through wait_for_finish instead of the generic message.

* feat(cli,mcp): expose internal agents and document --no-wait

paseo run gains --internal and create_agent gains internal, both forwarded
to the create request. --no-wait becomes the documented spelling for
returning immediately, matching paseo send; --background and --detach stay
as hidden aliases under COMPAT tags.

* test: follow the --no-wait rename and stub getRetiredInternalAgent

* feat: list internal agents on request and add internal workspaces

Internal agents stay hidden by default, but a caller can now ask for
them: `fetch_agents_request` takes `filter.includeInternal`, snapshots
carry `internal: true`, and `paseo ls --internal` shows them in an
INTERNAL column. The app never passes the flag.

Workspaces get the same flag. `workspace.create.request` with
`internal: true` persists a hidden workspace: listings, update
subscriptions, sync reads, empty-project counting, open-by-path, MCP
`list_workspaces`, and plugin workspace hooks all skip it unless the
caller opts in with `filter.includeInternal`. Every agent created inside
an internal workspace is internal and unpersisted; AgentManager applies
that from the workspace id before the plugin hook so creators cannot
opt out. Archive and shutdown enumerate internal agents so nothing
inside an internal workspace outlives it.

Surfaces: `paseo workspace create --internal`, `paseo workspace ls
--internal`, `paseo run --internal --new-workspace`, MCP
`create_workspace({ internal })`, SDK `workspaces.create({ internal })`
and `list({ filter: { includeInternal } })`. Clients gate on
`features.internalWorkspaces`.

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

* feat(workspaces): add background visibility with inherited creation defaults

* fix(cli): resolve workspace callers on the selected daemon

* test(workspaces): keep caller integration coverage in server suite

* test(cli): exercise workspace callers through CLI integration harness

* test(mcp): remove obsolete execution flags from browser fixtures

---------

Co-authored-by: marvin-ambrozio <marvin@gustavo.eng.br>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-09 09:31:50 +01:00
Mohamed Boudra 29db2b7980 Keep created agents after first-prompt failures and bound fork history (#6379)
* fix(agents): preserve creation when the first prompt fails

A rejected initial prompt must leave a created agent with a turn error instead of returning the user to a draft whose creation key is already committed. Preserve receipt replay without repeating the prompt, and bound fork history to 100,000 characters before it becomes an attachment.

* test(creation): isolate user agent counters from background naming

Workspace naming uses internal provider sessions. Counting and rejecting those sessions made creation assertions depend on installed provider discovery timing. Keep discovery within the test adapters and scope observations to user-created agents.

* test(creation): retry temporary directory cleanup on Windows

Windows CI completed the rejection assertions but failed teardown with EBUSY. Match the daemon test harness bounded removal retries so transient directory locks do not fail creation tests.

* fix(agents): record rejected prompts before their errors

Startup rejection recorded only the system error, leaving the client to append its local prompt after it. Record the rejected submission at the terminal failure boundary so stream and fetched history agree on prompt-before-error ordering.

* fix(agents): require acceptance for delivery notifications

Retain rejected human submissions before their errors without projecting rejected inter-agent prompts as completed deliveries. Reproduces and fixes the notification regression exposed by merge CI.

* test(agents): retain rejected submissions across retries

The existing browser retry test assumed terminally rejected prompts disappeared. Assert the intended prompt-before-error history and a separate accepted retry while preserving draft, attachment, and ambiguous-steering checks.

* test(creation): allow integration latency in raw RPC responses

The peer inherited a one-second polling timeout despite exercising Git and provider startup. Reproduce the timeout with delayed startup and wait within an explicit integration budget while retaining all response and identity assertions.
2026-10-08 22:25:57 +01:00
Mohamed Boudra 8c096b5e33 docs: remove Linux setup detour from getting started (#6415) 2026-10-08 22:16:16 +01:00
Mohamed Boudra d0bc188a5b Show agent messages and completion notifications with sender identity (#6373)
* feat(agents): show agent messages and completion notifications with sender identity

* fix(agents): preserve human envelope examples and attachment context

* fix(agents): separate message provenance from timeline presentation
2026-10-08 14:42:34 +01:00
Leandro Ardissone 06fe97c901 fix(server): keep home directory search out of ~/Library on macOS (#5789)
Discovery from the home directory walked into ~/Library, where readdir on
TCC-protected group containers blocks forever for a daemon that cannot show
a consent prompt. Each blocked call holds a libuv threadpool thread, so a few
searches wedged every fs operation in the daemon.

- Add an excludedDiscoveryPaths option; discovery skips those paths, while
  exact typed paths still resolve and browsing inside lists one level.
- Resolve rooted typed paths directly and anchor the walk under an existing
  typed parent instead of walking the whole home directory.

Refs #4879
2026-10-08 11:33:32 +01:00
az33zy f943e790b6 fix(usage): tint the OpenCode usage icon through currentColor (#6362) 2026-10-08 12:23:30 +02:00
Mohamed Boudra 23d7a089e2 fix(website): keep plugin carousels from widening the mobile page (#6367)
Anchor the absolutely positioned screen reader install label to its count. Without a containing block inside the carousel, the invisible label escapes the scroll container and expands the document.
2026-10-08 11:15:14 +01:00
paseo-bot[bot] 99fc204c55 fix(opencode): browser screenshots reach OpenCode v2 agents (#6330)
* fix(opencode): return browser screenshots to OpenCode v2 agents

The v2 bridge plugin passed a Paseo tool's MCP content straight to
OpenCode. OpenCode v2 tool content is text or a file, so the image block
from browser_screenshot was not valid output: the tool call never
completed and the turn failed.

Convert image blocks to file content with a data URI and keep text
blocks as they are.

Closes #6318

* fix(opencode): show v2 tool images as images, not base64 output

With the bridge now returning screenshots to OpenCode v2 as data URI
file content, the v2 timeline copied each file's URI into the tool
call's output, so the screenshot card showed the whole base64 string.

Keep data URI images out of the tool output, leave an "[image]"
placeholder, and emit each image after the tool call as image markdown,
the way Claude and Codex tool images are shown. History replay uses the
same mapping.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-08 01:01:21 +02:00
paseo-ai[bot] 33eb36c021 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-07 21:04:20 +00:00
paseo-bot[bot] 0fc6bf0c94 fix(schedule): keep firing after a run that could not be recorded (#6334)
* fix(schedule): keep firing after a run that could not be recorded

A schedule whose running-run write failed stayed marked as running in
memory, so every later tick skipped it and run-once reported it as
already running until the daemon restarted. The running mark is now
cleared whenever the run ends, including when it could not be recorded,
and the next tick retries the due run.

* test(schedule): skip the unwritable-directory test as root

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 22:55:19 +02:00
Mohamed Boudra 2d55870490 docs(website): present Paseo as an agentic development environment (#6336) 2026-10-07 21:33:34 +01:00
Mohamed Boudra ab10a6694c chore(release): cut 0.11.1 2026-10-07 22:30:39 +02:00
Mohamed Boudra 2a151a4494 docs: changelog for 0.11.1 2026-10-07 22:23:15 +02:00
Mohamed Boudra 9fcae41c98 feat(claude): add Haiku 5.5 with its model capabilities (#6332) 2026-10-07 21:00:11 +01:00
Mohamed Boudra b352dad2e3 fix(website): put Plugins first in the header 2026-10-07 21:27:17 +02:00
paseo-bot[bot] 1ae979d3d0 Subagents that finish while the app is disconnected no longer stay "working" (#6316)
* fix(app): catch up provider subagents after the update feed reconnects

Subagent updates are not replayed across a reconnect, so a child that
finished while the app was disconnected stayed "working" until one
subagent was opened and its parent was listed again. Re-list every
parent the app holds once the update feed resubscribes.

Closes #6109

* fix(app): resync a subagent parent even when its first list failed

A parent's subagent track lists the parent once on mount. When that list
failed because the app was disconnected, the parent was never tracked,
so the reconnect resync skipped it and children that later arrived via
live updates stayed stale after the next drop. Track the parent when its
list is requested instead of when it succeeds.

* test(website): expect the plugin directory links now that they are on

1b88f1612 turned PLUGINS_LINKED on, but the e2e test still asserted the
directory was unlinked, failing the typecheck job on main and every PR.
Assert the navigation, footer, and Browse plugins links point at /plugins.
The header check now targets the navigation landmark the homepage renders;
the banner locator matched nothing, so it only passed while asserting zero.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 18:30:20 +02:00
paseo-bot[bot] d46164ce0d fix(claude): keep long-running tool calls out of the subagents track (#6308)
Claude Code sends a tool_progress heartbeat every 30 s while a tool is
still running, with that tool's own id as parent_tool_use_id. The Claude
adapter routed any frame with a parent id to the sidechain tracker, so
a main-thread call that ran past 30 s (MCP, WebFetch, WebSearch) became
a "Claude subagent" row with a child action per heartbeat that never
completes, and its card was relabeled Task.

A heartbeat now names no sidechain, so it never reaches subagent
routing.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 17:17:17 +02:00
Mohamed Boudra a591320409 docs(changelog): announce the plugin registry in 0.11.0 2026-10-07 16:38:38 +02:00
paseo-bot[bot] ee6deda079 Show a Claude agent's history when its transcript is under another project folder (#6301)
* fix(claude): load history from a transcript under another project folder

Claude Code resumes a session by id from any working directory and keeps
writing to the transcript in the project folder where the session started.
When the agent's cwd encodes to a different folder (a moved project, a
migrated daemon, a Windows binary under WSL), the agent kept working but
reopened with an empty timeline. Look the transcript up by session id
across the config dir's project folders when it is not where cwd points.

Refs #4540

* fix(claude): report a failed project folder listing instead of an absent transcript

Take the transcript lookup's inputs as one object, and rethrow read errors other than a missing projects folder so the history load logs them as a failure.

* test(claude): skip the unreadable projects folder case when running as root

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 16:09:34 +02:00
paseo-ai[bot] 0555c877d0 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-07 13:49:31 +00:00
Mohamed Boudra b6454d7ea4 fix(website): show paseo plugin add as the registry install command 2026-10-07 15:33:25 +02:00
Mohamed Boudra 1b88f16124 feat(website): link the public plugin registry 2026-10-07 15:33:25 +02:00
Mohamed Boudra 1fae8e89f7 fix(website): mute footer column titles by shade instead of weight 2026-10-07 15:33:25 +02:00
Mohamed Boudra 22488d4502 chore(release): cut 0.11.0 2026-10-07 15:27:08 +02:00
Mohamed Boudra 2a5264c188 docs: changelog for 0.11.0 2026-10-07 15:05:53 +02:00
8ddeb79c8d Claude agents stay idle while background helpers work, and send or Stop no longer kills them (#6295)
* fix(claude): open autonomous turns only when Claude starts a turn

Paseo guessed from frame shape whether the main session had started work
on its own, and only a result could close the turn that guess opened. A
background subagent's task frames, its own background jobs'
task_notification and rate limit events all arrive with no parent tool
use id, so they looked like main-session work and opened a turn no result
would ever close. The agent then reported "running" while its main
session sat idle, and the app's message queue never drained.

Claude brackets every main-session turn itself: system/init opens it and
exactly one result closes it, whether a prompt, a wake-up after background
work, a slash command or an API error started it. Background subagents
never emit either on the main stream. An autonomous turn now opens only on
that init, and only when no foreground turn is open. The frame heuristic
is removed.

- Paseo launches the CLI with CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1,
  added as an overlay in buildSdkEnv so the SDK and the usage probe share
  one env object. An "idle" session state closes an autonomous turn as a
  safety net, never a foreground one. "running" opens nothing: Claude Code
  2.1.280 stays "running" for as long as any background subagent is
  alive. State frames never reach the timeline.
- An init clears a leftover pendingInterruptAbort. A new turn has begun,
  so the interrupted one is over, and a stale flag would swallow the new
  turn's result.

Tests that opened autonomous turns from bare frames now send the init
Claude sends. New coverage: helper frames never open a turn while the main
session is idle, a wake-up turn opens on init and closes on its result, a
folded steer keeps one turn while a turn Claude starts after the result
gets its own, an idle closes a turn no result closed, session state never
opens a turn, and the env flag reaches the SDK launch.

docs/agent-lifecycle.md records that only system/init and result bracket
a main-session turn, since the code cannot tell a reader why frame shape
fails.

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

* fix(claude): interrupt Claude only while its main turn runs, and spare helpers

Every Stop, and every send that replaces a running turn (paseo send, MCP
send_agent_prompt, voice, the app's queue drain, Send now), ends in
query.interrupt(). Without the SDK's perTaskStopAffordance declaration
that call kills every running background subagent, even when the main
session is idle and there is nothing of ours to stop.

- Declare perTaskStopAffordance, so an interrupt aborts only the main
  turn, like Esc in Claude Code. Claude stops a helper itself with its
  TaskStop tool.
- Track whether a main-session turn is in flight from Claude's own
  brackets: set by system/init and by a pushed message's
  command_lifecycle "started" (/compact works for seconds before its
  init), cleared by the result or an idle session state.
- Call query.interrupt() and set pendingInterruptAbort only when that
  turn is in flight, or the cancelled turn's own message has already been
  started. Otherwise withdraw our not-yet-started messages with
  cancel_async_message instead: an idle interrupt reaches only helpers,
  and with no result to follow it the flag would linger.
- Track the foreground prompt with the queued steers until Claude starts
  it, so Stop withdraws a message still waiting in the queue. A turn
  stopped while the Claude process is still starting never sends it.
- Accept command_lifecycle "cancelled" as terminal, so a cancelled steer
  no longer waits to be withdrawn or keeps denying permission cards.

Interrupt tests now wait for the pump to read the init before stopping,
since that is when Paseo learns a turn is in flight. New coverage: a stop
before Claude starts the message withdraws it without interrupting, a
stop while only helpers run never reaches Claude, a stop mid-turn
interrupts once and drops the aborted result, a new turn clears a flag
no result will consume, the SDK option is passed, and a cancelled steer
is not withdrawn again.

docs/providers.md described the old unconditional interrupt; it now states
when the Claude adapter interrupts and why it declares the affordance.

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

* fix(claude): keep a helper spawned in the background out of a turn's cancel

When a turn is canceled, Paseo terminalizes the subagents that were
running in its foreground and skips the backgrounded ones, which outlive
it. It only learned a child was backgrounded from task_updated's
patch.is_backgrounded, which Claude sends when a foreground task moves to
the background later. A child spawned in the background carries the flag
on task_started instead, and a resumed subagent always does, so the
track marked those helpers "canceled" while they kept working.

Read is_backgrounded from task_started as well. docs/agent-lifecycle.md
said task_updated was the only signal; it now names both.

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

* fix(app): drain queued messages as steers

The composer queue drains when an agent goes idle. It sent each message
with no activeTurnBehavior, which the daemon treats as "interrupt": if
the agent had just started a turn of its own (a Claude wake-up after
background work finished), the drained message stopped that turn and
replaced it.

Send drained messages with activeTurnBehavior "steer". A drain that races
such a turn folds into it; with no turn running the daemon starts a
normal one, as before. "steer" is an existing protocol value, so older
daemons need nothing new.

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

* test(claude): cover background helpers against real Claude

Every other test in this file tells Claude "Do not use a background
task", so a message sent or a Stop pressed while a backgrounded subagent
runs was never exercised. Four real-Claude scenarios cover it:

- "a background subagent keeps running after a message is sent while it
  works": one helper, the main turn ends, the parent is not reported
  running, a message in that window is answered, and the helper is never
  read as canceled and completes.
- "a helper's own nested background job does not phantom-run the parent
  or kill the helper": a helper starts its own background Bash job, then
  keeps working in the foreground. The parent stays idle across the
  nested job's completion (sampled for 30 s around a 5 s job), a message
  sent in that window is answered, and the helper reports CHILD_DONE.
- "the app's default steer survives a backgrounded helper while the main
  session works": the app's default send behavior (steer) does not kill
  a helper when it interrupts the main session's own turn.
- "Stop cancels the main session's own turn but spares its background
  helper": the same for Stop.

The wake-up turn Claude starts after a helper finishes lands a moment
after the helper reads completed (0.1 to 5 s in recorded traces), and the
daemon announces it as a pushed "running" agent_update, not as a
turn_started stream event. The checks poll for it and read the final idle
once the wake-up settles. waitForFinish can return a stale "idle" right
after a send, and the push stream can miss a message's final text when it
resolves, so the tests read fetchAgentTimeline like the rest of the file.
Helper status comes from agent.provider_subagents.list, since agent_stream
carries no provider_subagent events.

The scenarios need the previous commits together. Run against the frame
heuristic this branch removes, the nested-job test fails: the parent
flips to running once the nested job finishes and stays there.

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

* fix(claude): interrupt a stopped prompt Claude has already taken

Stop before Claude announces a prompt started withdraws it with
cancel_async_message instead of interrupting. Claude can dequeue a
message for its next turn before announcing it, and withdrawing it is
then a no-op that reports cancelled: false, so the stopped prompt kept
running. Interrupt whenever a message could not be withdrawn.

Co-authored-by: Isak Persson <isak.a.persson@gmail.com>

* fix(claude): withdraw a stopped prompt only once

When the withdrawal of a stopped prompt failed, the interrupt path
withdrew a second time, which could cancel a prompt sent while the first
withdrawal was in flight. Withdraw once and interrupt on failure.

The real-Claude steer scenario now waits for the background helper to
exist before steering, so it proves the helper survives the steer.

Co-authored-by: Isak Persson <isak.a.persson@gmail.com>

---------

Co-authored-by: Isak Persson <isak.a.persson@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-07 14:08:48 +02:00
paseo-bot[bot] c90587e5b7 Keep a plugin search term typed while the website page is still loading (#6293)
* test(website): reproduce plugin search dropping a term typed before hydration

Typing in the plugin search box before the page's scripts run leaves the
term in the box but not in React state, so Enter searches without it.

* fix(website): keep a plugin search term typed before the page loads

Text typed into the plugin search box before hydration stayed in the box
but not in component state, so Enter searched without it and the clear
button never appeared. Adopt the box's value once the page hydrates.

Closes #6286

* test(website): cover live browse search typed before the page loads

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 12:49:37 +02:00
Mohamed Boudra 56f4fca9e4 docs(workspaces): guide parallel development and review (#6294) 2026-10-07 11:40:18 +01:00
paseo-bot[bot] 28f7528588 docs: fix the Tailscale Serve command and log rotation defaults (#6284)
The web UI guide gave the pre-1.52 `tailscale serve https / <target>`
form, which current Tailscale rejects with "invalid number of
arguments". The configuration page said logs keep 2 files in total,
while the daemon keeps 3 rotated files next to the active one.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 09:51:17 +02:00
paseo-bot[bot] f13bb2136c fix(server): keep accented names on downloaded files (#6281)
A file name that fits in Latin-1, like café.txt, went out as a raw byte in
Content-Disposition filename= with no filename* parameter, and Chromium saved
the file under a name taken from the URL (download.txt). Build the header so
every non-ASCII name carries filename*=UTF-8''… with an ASCII fallback.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 08:57:29 +02:00
paseo-bot[bot] 2323ee6844 Keep reporting a Linux daemon as running after its VM resumes from a host sleep (#6279)
* Keep reporting a Linux daemon as running after its VM resumes from a host sleep

A VM paused while its host sleeps keeps its uptime, but its wall clock
catches up on resume. The boot instant derived from the two then lands
after the running supervisor's lock, so daemon status reported it as
stopped and daemon stop released its lock without stopping it.

The lock now records Linux's boot id, and a lock that carries one is
judged by it. Locks without one keep the wall-clock check.

Closes #6019

* Give the OMP idle-deadline test a deadline a slow runner cannot pass before the first wait

With a 1 ms deadline, the gate's first check, made synchronously when the
turn ends, could already be past it on a loaded Windows runner. The turn
then failed before the test's first scheduler wait, and the test timed out.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 07:51:12 +02:00
1e00f452ea Download files whose names have non-ASCII characters (#6278)
* fix(server): encode Unicode download filenames in Content-Disposition

* test(server): cover Korean download filenames

* test(server): validate download token and agent ID formats

* fix(server): set the download header through Express instead of a new dependency

The download route already runs on an Express response, and res.attachment()
builds Content-Disposition with the same content-disposition encoder that
Express ships. Use it and drop the direct content-disposition dependency and
its type package. The header values the download tests assert are unchanged.

Co-authored-by: jasonhnd <3293256+jasonhnd@users.noreply.github.com>

---------

Co-authored-by: Dillon <dillonzq@outlook.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: jasonhnd <3293256+jasonhnd@users.noreply.github.com>
2026-10-07 06:59:53 +02:00
paseo-bot[bot] a162e1075f fix(acp): apply configured models the agent does not advertise (#6273)
* fix(acp): apply configured models the agent does not advertise

A model added through a provider's models or additionalModels was listed
in the picker, but an ACP session only accepted ids the agent advertised
in session/new, so the agent silently ran on its default model. The
registry now passes configured model ids to the session, which applies
them through session/set_model. Unconfigured, unadvertised ids are still
skipped with a warning.

* fix(acp): send configured models when the agent advertises none

Also covers the resume path in the regression test.

* test(acp): keep configured-model tests in the ACP agent suite

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 06:15:19 +02:00
paseo-bot[bot] 8f1091da64 Let a Claude agent leave Auto mode when Claude Code uses Bedrock or Vertex (#6272)
* fix(claude): let an agent leave Auto mode when Claude Code uses Bedrock or Vertex

Changing the mode of a Claude agent whose process is not running starts the
process first. It was started in the agent's current mode, so an agent in Auto
on Bedrock or Vertex failed the Auto check before the new mode applied, and
every mode change and message failed the same way.

Start the process in the requested mode instead.

Closes #5878

* fix(claude): relaunch a query whose mode change Claude Code rejected

A mode change that starts the query now launches it in the requested mode. If
Claude Code then rejects the mode, the agent keeps its previous mode, so mark
that query for relaunch instead of reusing it in the rejected mode.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 04:55:01 +02:00
Bae Seongmin 4dd0e50e78 fix(server): time out stalled encrypted relay handshakes (#5648) 2026-10-07 03:53:18 +02:00
paseo-bot[bot] dc785bb29a Keep both subagent cards labeled when Claude launches two background agents together (#6268)
* fix(server): keep a subagent card labeled when two background agents launch together

The daemon's stream coalescer kept only the last update per tool call
inside its 60 ms window. When Claude launches two background Agent calls
in one message, the first child's task_started arrives a few ms after the
second call starts streaming, so its labeled card is buffered and then
overwritten by the unlabeled completed Agent update. The coalescer now
folds updates with the same merge the timeline projection applies, so a
labeled detail is not replaced by an unknown one.

Closes #6252

* refactor(server): compute the merged tool call before building the coalescer entry

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 02:54:09 +02:00
Mohamed Boudra 93bfb34e21 feat(website): copy states what Paseo does, plugins section and comparison tables (#6270)
The homepage, README, sponsor copy and comparison pages now say plainly
what Paseo is: a one-click desktop app with the daemon bundled, a native
mobile app with full parity, parallel agents in worktrees, and plugins
that add panels and functionality on desktop, web and mobile.

Every comparison page uses one table with the same rows so readers can
compare products row by row. The product columns use a fixed layout so
column widths no longer follow the longest cell.

Sponsor copy opens with the project's size before the one-person fact,
so a reader does not take "one person" to mean a side project.
2026-10-07 00:59:14 +01: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
366 changed files with 16824 additions and 3990 deletions
+54 -6
View File
@@ -1,12 +1,34 @@
# Changelog
## 0.11.0-beta.5 - 2026-10-06
## 0.11.2 - 2026-10-09
### Fixed
- Fixed intermittent text clipping in iOS chats and Markdown previews ([#6445](https://github.com/getpaseo/paseo/pull/6445))
## 0.11.1 - 2026-10-07
### Added
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer ([#6332](https://github.com/getpaseo/paseo/pull/6332))
### Fixed
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents ([#6308](https://github.com/getpaseo/paseo/pull/6308))
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened ([#6316](https://github.com/getpaseo/paseo/pull/6316))
- Fixed a Claude agent's history showing "No activity to display" after a daemon restart when its transcript is in another project folder, such as after moving the daemon or running Windows Claude Code from WSL ([#6301](https://github.com/getpaseo/paseo/pull/6301))
## 0.11.0 - 2026-10-07
Paseo 0.11 adds support for the [plugin registry](https://paseo.sh/plugins). Browse community plugins there, install one with `paseo plugin add owner/slug`, or [submit your own](https://paseo.sh/docs/plugins/publishing#plugin-registry).
**Before upgrading:** `paseo plugin add owner/repo` now installs from the registry, so use `github:owner/repo` to install straight from GitHub and `./path` for a local directory.
### Added
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls ([#5719](https://github.com/getpaseo/paseo/pull/5719), [#5775](https://github.com/getpaseo/paseo/pull/5775) by [@millerben95](https://github.com/millerben95))
- Added Antigravity as a provider that drives the installed `agy` CLI, always in Full access ([#5714](https://github.com/getpaseo/paseo/pull/5714), [#5795](https://github.com/getpaseo/paseo/pull/5795))
- Added a Usage screen, opened from the sidebar footer, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921))
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh ([#5465](https://github.com/getpaseo/paseo/pull/5465), [#5685](https://github.com/getpaseo/paseo/pull/5685), [#5786](https://github.com/getpaseo/paseo/pull/5786), [#5805](https://github.com/getpaseo/paseo/pull/5805), [#5921](https://github.com/getpaseo/paseo/pull/5921), [#6216](https://github.com/getpaseo/paseo/pull/6216))
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin ([#5903](https://github.com/getpaseo/paseo/pull/5903), [#5975](https://github.com/getpaseo/paseo/pull/5975), [#6089](https://github.com/getpaseo/paseo/pull/6089))
- 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))
@@ -16,19 +38,26 @@
- 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))
- Added a Content width setting for the maximum width of chat and Markdown files on wide screens ([#5680](https://github.com/getpaseo/paseo/pull/5680))
- Added syntax highlighting for Vue files ([#6205](https://github.com/getpaseo/paseo/pull/6205) by [@Davnn1](https://github.com/Davnn1))
- Added `agents.providers.<id>.options` in `config.json` as default options for every provider, with per-agent overrides ([#5780](https://github.com/getpaseo/paseo/pull/5780))
- 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))
### Plugins
- Changed `paseo plugin add owner/slug` to install the reviewed plugin from the plugin registry, so local directories need a path such as `./slug` and direct GitHub installs need `github:owner/repo` ([#6224](https://github.com/getpaseo/paseo/pull/6224))
- Added [screens and sidebar header and footer items](https://paseo.sh/docs/plugins/reference#screens-and-sidebar-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))
- Added a display name, icon, screenshots, and videos to the plugin manifest ([#6263](https://github.com/getpaseo/paseo/pull/6263))
- Added an `OVERVIEW.md` template to `paseo plugin init` for plugin registry listings ([#6226](https://github.com/getpaseo/paseo/pull/6226))
### Changed
- 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 details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975), [#6185](https://github.com/getpaseo/paseo/pull/6185))
- 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 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))
@@ -44,12 +73,18 @@
### 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, or as "Agent" after a daemon restart ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100))
- Fixed a Claude agent showing as running while only its background helpers worked, and a send or Stop killing those helpers ([#6295](https://github.com/getpaseo/paseo/pull/6295) by [@IsakPersson](https://github.com/IsakPersson))
- Fixed a Claude agent in Auto mode failing to switch modes when Claude Code uses Bedrock or Vertex ([#6272](https://github.com/getpaseo/paseo/pull/6272))
- Fixed a Claude subagent's auto-mode report showing as a raw SubagentHandback tool card instead of markdown ([#6248](https://github.com/getpaseo/paseo/pull/6248))
- Fixed background Claude subagent cards showing as a bare "Task" that stayed running, as "Agent" after a daemon restart, or unlabeled when two launch together ([#6067](https://github.com/getpaseo/paseo/pull/6067), [#6100](https://github.com/getpaseo/paseo/pull/6100), [#6268](https://github.com/getpaseo/paseo/pull/6268))
- 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 running on their default model when the configured model is one the agent does not advertise ([#6273](https://github.com/getpaseo/paseo/pull/6273))
- Fixed archiving an ACP agent leaving its provider running when the provider never answers close ([#6213](https://github.com/getpaseo/paseo/pull/6213) by [@apple-ouyang](https://github.com/apple-ouyang))
- Fixed an archived ACP agent showing an empty chat after its worktree was removed ([#6228](https://github.com/getpaseo/paseo/pull/6228))
- 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))
@@ -64,12 +99,14 @@
- 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 reopened OMP chats showing "Custom message" rows for compactions and other entries OMP writes on its own ([#6198](https://github.com/getpaseo/paseo/pull/6198), [#6215](https://github.com/getpaseo/paseo/pull/6215))
- 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 OpenCode v2 agents staying running after a question is dismissed or a tool permission is denied ([#6177](https://github.com/getpaseo/paseo/pull/6177), [#6188](https://github.com/getpaseo/paseo/pull/6188))
- 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))
@@ -83,12 +120,18 @@
- Fixed Codex terminal rows reading only "Terminal" without the command on current Codex ([#5774](https://github.com/getpaseo/paseo/pull/5774))
- Fixed the composer showing the catalog default thinking level instead of the one the agent runs on ([#5804](https://github.com/getpaseo/paseo/pull/5804))
- 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 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), [#6172](https://github.com/getpaseo/paseo/pull/6172))
- 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 copying a chat selection across an image losing its alt text and source, or including the image when the selection started below it ([#6181](https://github.com/getpaseo/paseo/pull/6181), [#6195](https://github.com/getpaseo/paseo/pull/6195))
- 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 text typed by the browser tool appearing in your message box instead of the browser tab ([#6207](https://github.com/getpaseo/paseo/pull/6207))
- Fixed Astro expressions losing their colors after a nested template string ([#6219](https://github.com/getpaseo/paseo/pull/6219))
- Fixed downloading files whose names contain non-ASCII characters ([#6278](https://github.com/getpaseo/paseo/pull/6278), [#6281](https://github.com/getpaseo/paseo/pull/6281) by [@dillonzq](https://github.com/dillonzq), [@jasonhnd](https://github.com/jasonhnd))
- Fixed the PR panel, sidebar workspace status, and "Worked for" line showing English in other app languages ([#6196](https://github.com/getpaseo/paseo/pull/6196), [#6208](https://github.com/getpaseo/paseo/pull/6208), [#6225](https://github.com/getpaseo/paseo/pull/6225) by [@dillonzq](https://github.com/dillonzq))
- Fixed French translations that read as word-for-word machine translation ([#6004](https://github.com/getpaseo/paseo/pull/6004) by [@fxlelouarn](https://github.com/fxlelouarn))
- 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))
@@ -96,12 +139,16 @@
- Fixed a new worktree workspace being archived when its `paseo.json` cannot be parsed ([#6036](https://github.com/getpaseo/paseo/pull/6036) by [@3ae3ae](https://github.com/3ae3ae))
- 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 dictation failing until the model was deleted by hand when the daemon stopped while extracting a local speech model ([#6210](https://github.com/getpaseo/paseo/pull/6210) by [@3ae3ae](https://github.com/3ae3ae))
- Fixed macOS hosts showing as `UNKNOWN` when the network supplies no hostname ([#5922](https://github.com/getpaseo/paseo/pull/5922))
- Fixed `paseo daemon status` reporting a daemon in a Linux VM as stopped after the host wakes from sleep ([#6279](https://github.com/getpaseo/paseo/pull/6279))
- Fixed relay reconnects being ignored after a relay connection stalled before its handshake ([#5648](https://github.com/getpaseo/paseo/pull/5648) by [@3ae3ae](https://github.com/3ae3ae))
- 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 failed send to an archived agent whose directory was removed taking it out of History ([#6255](https://github.com/getpaseo/paseo/pull/6255))
- 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))
@@ -121,6 +168,7 @@
- Fixed `paseo schedule update --no-max-runs` and `--no-expires-in` failing before reaching the daemon ([#5973](https://github.com/getpaseo/paseo/pull/5973) by [@Mar-garet](https://github.com/Mar-garet))
- Fixed `daemon.log` saying nothing when a resumed Claude agent's transcript is missing or cannot be read ([#5818](https://github.com/getpaseo/paseo/pull/5818) by [@gabrielgiordan](https://github.com/gabrielgiordan))
- Fixed reloading a plugin adding `[System Error] Provider connection closed` to finished chats on its provider ([#5579](https://github.com/getpaseo/paseo/pull/5579))
- Fixed `paseo plugin add .` looking for the plugin in the daemon's directory instead of the CLI's ([#6239](https://github.com/getpaseo/paseo/pull/6239))
- Fixed plugin settings screens on desktop having no way back to the Plugins page ([#5620](https://github.com/getpaseo/paseo/pull/5620))
- Fixed plugin client code failing to import npm packages that declare only `main` or `module` ([#5838](https://github.com/getpaseo/paseo/pull/5838))
- Fixed the SDK's `send()` and `run()` rejecting `activeTurnBehavior` in TypeScript ([#5605](https://github.com/getpaseo/paseo/pull/5605) by [@wjxdy](https://github.com/wjxdy))
+21 -9
View File
@@ -39,21 +39,33 @@
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
</p>
Run agents in parallel on your own machines. Ship from your phone or your desk.
Paseo is an open source agentic development environment for desktop, mobile, web, and CLI. Open the desktop app and work: agents, editor, terminals, diffs, pull requests, and a browser in one window. Run many agents at once, each in its own worktree, on one machine or several. The mobile app is the full app, native on iOS and Android.
- **Parallel agents:** Run many agents at once, each in its own worktree.
- **Built-in orchestration:** Agents in Paseo can create worktrees, launch other agents, and talk to them, across providers.
- **Complete development workflow:** Edit files, review diffs, open pull requests, and run terminals, in split panes you arrange how you want.
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, Pi, Antigravity, and Muse Code through the same interface. Pick the right model for each job.
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
[Run parallel tasks in Paseo](https://paseo.sh/docs/parallel-development): start agents in separate worktrees, review their diffs, run each app, and check it in the built-in browser.
## Plugins
Add themes, workspace panels, commands, settings screens, and coding-agent providers with trusted
TypeScript plugins. Install from npm, Git, or a local directory with `paseo plugin install <source>`.
Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web,
iOS, and Android. Write a plugin once and it is on your phone.
Start with the [plugin quickstart](https://paseo.sh/docs/plugins). Plugins run with access to your daemon
machine and inside connected clients; install only code you trust.
- **UI:** screens, sidebar items, workspace panels, Command Center items, slash commands, composer pills, attachment sources, timeline items, themes.
- **Agent lifecycle:** change configuration, environment, and MCP servers, answer permissions, follow up when a turn ends.
- **Providers:** add a coding agent as a provider.
Install from the registry with `paseo plugin add owner/slug`, or from Git or a local directory.
**[Browse plugins](https://paseo.sh/plugins)** · **[Plugin docs](https://paseo.sh/docs/plugins)**
Plugins run with access to your daemon machine and inside connected clients; install only code you trust.
## Getting Started
@@ -77,16 +89,16 @@ Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub r
To connect from your phone, open **Settings → your host → Pair Device**.
### CLI / headless
### Server
Install the CLI and start Paseo:
For a server, a VM, or any machine without the desktop app. Install the CLI and start the daemon:
```bash
npm install -g @getpaseo/cli
paseo
```
Paseo starts locally, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. This path is useful for servers and remote machines.
Paseo starts, then asks whether to enable the end-to-end encrypted relay for device pairing. If you decline, connect directly over TCP, Tailscale, or another VPN. The desktop, mobile, and web apps connect to this daemon like any other host.
For full setup and configuration, see:
@@ -197,7 +209,7 @@ npm run typecheck
## Sponsors
Paseo is built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
+30 -3
View File
@@ -69,11 +69,11 @@ the marker. Closing a tab sets that client's label to `false`. Any `true` client
open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a
normal root agent immediately, and closing its still-open tab archives it.
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation only selects an existing workspace.
Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested.
Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
`notifyOnFinish` defaults to `true` for agent-scoped creation and prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission.
The permission notification includes the normalized request plus the child and request IDs, so the caller can inspect it and respond without fetching agent status.
A watched child that closes before its finish event also notifies the caller so delegated work cannot disappear silently during archive or workspace teardown.
@@ -84,6 +84,32 @@ Some providers can create their own child sessions inside one provider runtime.
The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.
## Background workspaces
`workspace.background` controls discovery only. Background workspaces contain ordinary durable
agents and terminals: provider history, plugin hooks, parentage, archive, and restart recovery
keep their normal behavior. Visibility is creation-only; selecting an existing workspace cannot
change it. New workspaces default to false, inherit the caller workspace's value when an agent
creates one without an override, and accept explicit true or false.
The daemon derives agent discovery from workspace ownership. Default workspace and agent lists,
including History requests, exclude background work; `filter.includeBackground` includes it.
Exact-ID operations bypass discovery filtering. The app replicates both kinds and filters only
its sidebar with **Show background**, so direct navigation and restored tabs remain available.
Sequenced directories keep complete canonical collections. A filtered read must never replace
those collections with its subset. Inclusive and default observations have distinct cursor
scopes, so changing scope requires a fresh snapshot rather than reusing an incomplete cache.
Clients gate creation and inclusive discovery on `features.backgroundWorkspaces`.
### Private daemon helpers
Branch-name and commit-message generators still use private disposable internal runtimes.
`AgentManager` does not persist them or their provider sessions, excludes them from public
discovery and plugin hooks, and drops their timelines on archive. An archived helper retains
its exact-ID result briefly to cover completion before a waiter attaches. This internal
lifecycle is separate from public workspace visibility and is not a public creation option.
## Archive
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
@@ -197,10 +223,11 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta
- **Not every announced task belongs in the track.** Task subagents announce as `local_agent` and workflows as `local_workflow`; a backgrounded shell announces as `local_bash` with the same `tool_use_id` shape, and ambient housekeeping sets `skip_transcript`. The Claude provider normalizes a workflow to a generic provider-subagent descriptor titled `Workflow`, using Claude's summary as its description and timeline opener. Shared storage, protocol, and UI do not distinguish it from another provider subagent.
- **A task that was never declared gets no descriptor, by any route.** Filtered tasks still emit `task_notification`s carrying a `tool_use_id`, and still emit frames carrying `parent_tool_use_id`. Attributing either produces a descriptor with no identity and a defaulted `running` status — a nameless row that never finishes. Status, presentation updates, and sidechain frames all route through the declaration table.
- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `task_updated.patch.is_backgrounded`.
- **Task ids are session-scoped, not turn-scoped.** Cancelling a turn must not clear the routing table: a backgrounded child settles after the interrupt and needs its descriptor to still exist. Cancellation instead terminalizes the declared children that were running in the foreground, and a later `task_notification` is free to correct that guess. Backgrounded children are identified by `is_backgrounded` on `task_started` (set at spawn; a resumed subagent is always background) or, when moved there later, on `task_updated.patch`.
- **A resumed task can be announced again with a new `tool_use_id`.** The first Task tool id remains the canonical descriptor and later ids are routing aliases for the same session-scoped task. The resumed prompt is added to that child timeline.
- **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`.
- **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them.
- **Only `system/init` and `result` bracket a main-session turn.** Background subagents emit neither on the main stream, while their task frames, their own background jobs' `task_notification`, and `rate_limit_event` arrive with no `parent_tool_use_id`, the same shape as main-session work. `session_state_changed` stays `running` for as long as any background subagent lives (Claude Code 2.1.280), so it cannot open a turn; its `idle` can close one.
- **Nested ownership comes from the launching sidechain, not `spawn_depth` alone.** A sidechain's Agent or Bash tool call records the direct owner of that `tool_use_id`; the following `task_started` inherits it. This routes a grandchild descriptor and child-owned background notifications without relying on labels or flattening them into the managed parent.
- **On replay, `<session>/subagents/` holds every descendant beside the root.** Resolve the tree one proven generation at a time: the root transcript admits direct children, then each admitted sidechain transcript admits its children by `toolUseId`. `spawnDepth` orders candidates but does not establish ownership. Unresolved sidecars remain excluded as ambient or unrelated work.
- **Replay `totalTokens` is a context-size reading, not cumulative spend.** Claude Code finalizes a subagent by summing the _last_ assistant message's usage block and shipping that as `usage.total_tokens`. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path.
+4
View File
@@ -345,6 +345,10 @@ join active work or reuse committed stages. A persisted resource alone cannot pr
that a provider accepted its initial prompt. Interrupted side effects with no conclusive
receipt return an unknown outcome instead of being repeated.
Agent registration commits creation. Rejecting the first prompt is a turn error on that
agent; reporting creation failure leaves an orphaned agent and invites another Create.
Receipt replay must not resend the prompt.
`packages/client/src/creation/` owns capability selection and legacy orchestration.
Callers always pass the initial prompt to agent creation. On an older host, the client
adapts keyed creation to the legacy create/send sequence; it cannot continue that
+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`).
+18 -13
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
@@ -488,7 +493,7 @@ 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, 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 screen 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.
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.
+3 -1
View File
@@ -61,6 +61,8 @@ Core direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`code
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
Keep the curated Claude catalog: `supportedModels()` has historically omitted older versions and carries less metadata. Discovery is only an opt-in visibility gate, not a replacement catalog or an entitlement guarantee. Claude Code v2.1.197 added `resolvedModel` to disambiguate alias rows; a bare family alias does not establish access to a particular version. Explicit user-configured models must remain selectable when discovery fails or omits them.
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. The daemon resolves `agents.providers.<provider>.paseoTools` by the exact provider ID. The catalog policy belongs to the caller: it filters the tools exposed to the current agent. When that agent calls `create_agent`, the child receives the policy for the child provider ID; the caller's policy is not inherited.
A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume the already-filtered `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`; the MCP server builds the same policy-filtered catalog for that caller. Filtering is enforced at catalog registration in both paths. Browser tools remain subject to the daemon browser-tools setting and browser-host availability.
@@ -103,7 +105,7 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. Pi sends its native `steer` RPC, which queues the message for delivery after the in-flight assistant turn's tool calls. Slash-command inputs report `unavailable` because pi rejects extension commands on the steer path, and echo identity is correlated by message text because pi's steer RPC takes no message ID. A missing session reports `unavailable` and uses the normal interrupt fallback.
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4.
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`. It calls `query.interrupt()` only while a main-session turn is in flight (between `system/init` and that turn's `result`): with the main session idle, an interrupt reaches only background subagents and no result follows it, so the adapter withdraws its unstarted messages and stops there. The adapter also declares `perTaskStopAffordance`, because without it the CLI kills every background subagent on interrupt. Pi requires `clear_queue` before `abort`; older binaries without that RPC retain their native queue behavior until the pi compatibility floor reaches 0.84.4.
`SteerActiveTurnOptions.clearPendingPermissions` makes permission release part of the provider contract. A provider that accepts such a steer queues it first, denies permissions blocking its delivery, and stops once the steer is read. Steers without the flag leave permissions open. A denied plan remains in the timeline because the pending card was the only other copy of its text.
+19 -5
View File
@@ -223,7 +223,7 @@ submission. Overlapping sends settle independently rather than collapsing to one
message.
Daemons advertising `server_info.features.canonicalSubmittedPrompts` guarantee that every accepted
prompt carrying a client message id is recorded and streamed as a canonical `user_message` with that
human prompt carrying a client message id is recorded and streamed as a canonical `user_message` with that
same id. This includes daemon-handled commands that do not allocate a foreground turn; their submitted
row is recorded before handler output. The app tracks submission transactions only for hosts with this
capability. Older hosts keep the shipped untracked optimistic-row behavior and roll that row back on RPC
@@ -238,11 +238,25 @@ foreground control ownership remains a separate daemon concern. Cancellation req
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
remain a separate pre-turn registry and retire on canonical acknowledgement.
Canonical turns and visible responses are different boundaries. System-injected prompts are absent from
the Paseo timeline, so one visible response can span several canonical turns without a user message
between them. Layout and copy group that response together; lifecycle, timing, tool sequences, and exact
Canonical turns and visible responses are different boundaries. Agent-originated prompts and lifecycle
notifications project into tool rows, so they do not create a human-message boundary. Legacy system
prompts, including schedule injections, remain hidden. One visible response can span several canonical
turns; layout and copy group that response together while lifecycle, timing, tool sequences, and exact
fork positions retain the canonical `turnId` boundaries.
Agent-message XML belongs to the daemon's `agent/agent-messages` boundary. Prompt delivery encodes
provenance; acceptance, provider echoes, import, and replay share its projection. The delivery ID is
also the projected call ID, so a provider echo cannot create a second row. Sender IDs are opaque and
do not imply local ownership or authorization. Capture the sender title at delivery: archived or
removed senders must remain understandable without a live directory lookup.
The optional `agentMessage` payload carries event, sender, and delivered text. Shared client
presentation (`protocol/agent-message-display`) owns its heading and expanded details. The daemon
must not construct UI labels or prepend sender labels to the semantic message body. The existing
`tool_call` / `plain_text` envelope is a compatibility adapter: older clients still parse the row and
expand its body. New clients use the typed payload, never infer the event from a tool name or parse
XML. Stream reduction and disk-cache serialization preserve the payload without rendering it.
The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an
anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app
does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a
@@ -250,7 +264,7 @@ second running state. Disconnect preserves the last replicated turn until cache
advances it; replica removal remains the destructive close boundary. Elapsed time comes only from turn
liveness, never from submission records or whichever timeline rows happen to be mounted.
The daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the
For human prompts, the daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the
submission's `clientMessageId`, so the row is born with its final identity and remains immutable on
the wire. A correlated provider echo records the provider's native identity internally without
dispatching another timeline event. Rewind resolves the wire identity to that provider identity at
@@ -0,0 +1,7 @@
Added
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Added
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Added
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,7 @@
Added
- Added Muse Code as a provider, with model, approval mode, and reasoning effort controls
- Added Antigravity as a provider that drives the installed agy CLI, always in Full access
- Added Usage, opened from the sidebar footer as a dialog on desktop and a sheet on mobile, showing each subscription account's quota windows with Refresh
- Added an opt-in Usage summary to the sidebar footer that shows the windows you pin
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
Fixed
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
Fixed
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
Fixed
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,8 @@
Added
- Added Claude Haiku 5.5 for Claude Code 2.1.293 and newer
Fixed
- Fixed Claude tool calls that run longer than 30 seconds, such as WebFetch or MCP tools, showing up as subagents
- Fixed subagents that finish while the app is disconnected staying "working" until one is opened
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed intermittent text clipping in iOS chats and Markdown previews
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed intermittent text clipping in iOS chats and Markdown previews
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed intermittent text clipping in iOS chats and Markdown previews
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed intermittent text clipping in iOS chats and Markdown previews
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-mQOyGIvwVt45hldUnYuZTKJg4mLPm7d8jXXNlSQnExE=
sha256-CK96G7MWsmd/VDvkuaFJ23U04EGNmeiXdTYbmR8RDDQ=
+29 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.11.0-beta.5",
"version": "0.11.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.11.0-beta.5",
"version": "0.11.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36413,7 +36413,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.11.0-beta.5",
"version": "0.11.2",
"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.5",
"version": "0.11.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/server": "0.11.0-beta.5",
"@getpaseo/client": "0.11.2",
"@getpaseo/protocol": "0.11.2",
"@getpaseo/server": "0.11.2",
"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.5",
"version": "0.11.2",
"dependencies": {
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/relay": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.2",
"@getpaseo/relay": "0.11.2",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37762,7 +37762,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.11.0-beta.5",
"version": "0.11.2",
"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.5",
"version": "0.11.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38902,7 +38902,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.11.0-beta.5",
"version": "0.11.2",
"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.5",
"version": "0.11.2",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.11.0-beta.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/client": "0.11.2",
"@getpaseo/protocol": "0.11.2",
"@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.5",
"@getpaseo/protocol": "0.11.0-beta.5",
"@getpaseo/client": "0.11.2",
"@getpaseo/protocol": "0.11.2",
"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.5",
"version": "0.11.2",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38998,7 +38998,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.11.0-beta.5",
"version": "0.11.2",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39216,16 +39216,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.11.0-beta.5",
"version": "0.11.2",
"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.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",
"@getpaseo/client": "0.11.2",
"@getpaseo/highlight": "0.11.2",
"@getpaseo/plugin": "0.11.2",
"@getpaseo/protocol": "0.11.2",
"@getpaseo/relay": "0.11.2",
"@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.5",
"version": "0.11.2",
"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.5",
"version": "0.11.2",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.11.0-beta.5",
"version": "0.11.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -284,7 +284,6 @@ async function expectRejectedSubmissionRestored(
await expectComposerDraft(page, input.prompt);
await expectComposerEditable(page);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await expect(page.getByTestId("user-message").filter({ hasText: input.prompt })).toHaveCount(0);
if (input.preservesActiveTurn) {
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
return;
@@ -295,13 +294,21 @@ async function expectRejectedSubmissionRestored(
async function retryRestoredSubmission(page: Page, prompt: string): Promise<void> {
await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter");
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(userMessage).toHaveCount(1);
const attempts = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(attempts).toHaveCount(2);
const userMessage = attempts.last();
await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
}
async function expectRejectedPromptBeforeError(page: Page, prompt: string, errorMessage: string) {
const messages = page.getByTestId("user-message").or(page.getByTestId("assistant-message"));
await expect(messages).toHaveCount(2);
await expect(messages.nth(0)).toContainText(prompt);
await expect(messages.nth(1)).toContainText(`[System Error] ${errorMessage}`);
}
async function configureSteerInSettings(page: Page): Promise<void> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+Comma`);
@@ -1173,13 +1180,14 @@ test.describe("Agent message submission", () => {
await toasts.expectNeverShown("agent-updating-toast");
});
test("restores a rejected submission and accepts its retry", async ({
test("retains a rejected submission before its error and accepts its retry", async ({
page,
rejectionScenario,
}) => {
const prompt = "Restore this rejected submission.";
await submitMessageThatWillBeRejected(page, prompt);
await expectRejectedSubmissionRestored(page, { prompt, ...rejectionScenario });
await expectRejectedPromptBeforeError(page, prompt, rejectionScenario.errorMessage);
await retryRestoredSubmission(page, prompt);
});
@@ -1209,6 +1217,7 @@ test.describe("Agent message submission", () => {
errorMessage: "Requested mock steer transport failure",
preservesActiveTurn: true,
});
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0);
expect(gate.getClientRequestCount("send_agent_message_request")).toBe(sendsBefore + 1);
expect(gate.getClientRequestCount("cancel_agent_request")).toBe(cancelsBefore);
@@ -168,6 +168,36 @@ async function selectAssistantImage(page: Page): Promise<void> {
});
}
/** 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);
}
@@ -705,6 +735,11 @@ test("copying a selection across an assistant image keeps its Markdown source an
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();
}
@@ -268,10 +268,16 @@ 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.refreshedAgents()).toEqual([agent.agentId]);
});
@@ -128,3 +128,46 @@ for (const scenario of [
await creation.expectAgentCount(1);
});
}
for (const entry of [
{
name: "agent draft",
open: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.openAgentDraft(),
button: "Send message",
settled: async (_creation: Awaited<ReturnType<typeof createCreationScenario>>) => {},
},
{
name: "new workspace",
open: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.openWorkspaceForm("local"),
button: "Create",
settled: (creation: Awaited<ReturnType<typeof createCreationScenario>>) =>
creation.expectOneCreatedWorkspace(),
},
]) {
test(`${entry.name} becomes an agent tab when the first prompt is rejected`, async ({
creation,
promptRejection,
page,
}, testInfo) => {
void promptRejection;
const prompt =
"Continue the attached conversation.\n" + "Earlier conversation context.\n".repeat(100);
await entry.open(creation);
await creation.submitPrompt(prompt, entry.button);
await entry.settled(creation);
await creation.expectCreatedAgentError();
await creation.expectPromptBeforeError(prompt);
await page.screenshot({ path: testInfo.outputPath("created-agent-prompt-error.png") });
await creation.reloadAgent();
await creation.expectPromptBeforeError(prompt);
await creation.submitPrompt("emit 1 coalesced agent stream updates for a corrected prompt.");
await creation.expectPromptVisible(
"emit 1 coalesced agent stream updates for a corrected prompt.",
);
await creation.expectAssistantReply();
await creation.expectAgentCount(1);
await expect(page.getByText("agent_request_key_conflict", { exact: true })).toHaveCount(0);
});
}
@@ -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);
@@ -1,6 +1,7 @@
import { test, expect, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
function workspaceRowTestId(workspaceId: string): string {
@@ -73,3 +74,75 @@ test.describe("Sidebar workspace rename", () => {
}
});
});
async function toggleBackgroundWorkspaces(page: Page): Promise<void> {
await page.getByLabel("Display preferences", { exact: true }).click();
await page.getByRole("menuitem", { name: "Show background", exact: true }).click();
await page.keyboard.press("Escape");
}
async function expectWorkspaceInSidebar(page: Page, workspaceId: string): Promise<void> {
// Rows have no single accessible role: their nested title and actions share this container.
await expect(page.getByTestId(workspaceRowTestId(workspaceId))).toBeVisible({ timeout: 30_000 });
}
async function expectWorkspaceHidden(page: Page, workspaceId: string): Promise<void> {
await expect(page.getByTestId(workspaceRowTestId(workspaceId))).toHaveCount(0);
}
async function reconnectApp(page: Page): Promise<void> {
await page.reload();
await expect(page.getByLabel("Display preferences", { exact: true })).toBeVisible();
}
async function expectWorkerOpen(page: Page): Promise<void> {
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({
timeout: 30_000,
});
await expect(page.getByText("Background reviewer", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Background review prompt", { exact: true }).first()).toBeVisible();
}
test("background workspace stays directly accessible across sidebar toggles and reconnect", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({
repoPrefix: "sidebar-background-",
title: "Background review",
background: true,
});
try {
const worker = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Background reviewer",
initialPrompt: "Background review prompt",
model: "e2e-fast-stream",
modeId: "load-test",
});
await test.step("Default sidebar hides background work", async () => {
await gotoAppShell(page);
await expectWorkspaceHidden(page, workspace.workspaceId);
});
await test.step("Show background exposes the fully replicated workspace", async () => {
await toggleBackgroundWorkspaces(page);
await expectWorkspaceInSidebar(page, workspace.workspaceId);
await reconnectApp(page);
await expectWorkspaceInSidebar(page, workspace.workspaceId);
});
await test.step("Direct agent navigation survives hiding and reconnect", async () => {
await toggleBackgroundWorkspaces(page);
await expectWorkspaceHidden(page, workspace.workspaceId);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: worker.id });
await expectWorkerOpen(page);
await expectWorkspaceHidden(page, workspace.workspaceId);
await reconnectApp(page);
await expectWorkerOpen(page);
await expectWorkspaceHidden(page, workspace.workspaceId);
await page.screenshot({ path: testInfo.outputPath("background-direct-open.png") });
});
} finally {
await workspace.cleanup();
}
});
@@ -429,7 +429,7 @@ test.describe("Sidebar workspace list", () => {
}
});
test("workspace hover card stays open for a keyboard-focused row", async ({ page }) => {
test("workspace hover card follows keyboard focus on a row", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-hover-keyboard-" });
try {
@@ -445,6 +445,11 @@ test.describe("Sidebar workspace list", () => {
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();
}
@@ -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,7 +324,7 @@ 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("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }),
).toBeVisible();
@@ -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");
@@ -501,7 +508,7 @@ test("an account with all logins failing shows each harness and remedy, then onl
]);
try {
await gotoAppShell(page);
await openUsage(page);
await openUsageFromIcon(page);
await expectLoginErrors(page, [
"Codex: Usage API returned 500. Try again.",
"OpenCode: Login rejected (HTTP 401). Run opencode to refresh it.",
@@ -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");
});
@@ -13,6 +13,11 @@ bash packages/app/e2e/mobile/native-text-reflow/ios.sh
The probe constructs the installed `RNUITextView`, fills its real UIKit text view,
and resizes its content container from 500 to 240 and back to 500 points. It
asserts the inner frame follows both widths and the same selection survives.
It then recycles the view the way Fabric's pool does and remounts it 30 points
wider and at the same size; the inner frame must still match the container.
A zeroed frame on recycle comes back 30 or 0 points wide. This assertion checks
the intermediate recycled geometry; it does not reproduce visible clipping in a
mounted Fabric paragraph or exercise the subsequent `drawRect` call.
It uses public UIView/UITextView operations, without mocking React Native.
LLDB detaches after the probe. An assertion failure or evaluation error fails the
shell command; the log distinguishes them.
@@ -2,10 +2,17 @@ import lldb
def __lldb_init_module(debugger, _internal_dict):
process = debugger.GetSelectedTarget().GetProcess()
main_threads = [thread for thread in process if thread.GetQueueName() == "com.apple.main-thread"]
if len(main_threads) != 1 or not process.SetSelectedThread(main_threads[0]):
raise RuntimeError("Could not select the UIKit main thread")
debugger.HandleCommand("expression -l objc++ -- @import UIKit")
frame = debugger.GetSelectedTarget().GetProcess().GetSelectedThread().GetSelectedFrame()
frame = process.GetSelectedThread().GetSelectedFrame()
options = lldb.SBExpressionOptions()
options.SetLanguage(lldb.eLanguageTypeObjC_plus_plus)
is_main_thread = frame.EvaluateExpression("(BOOL)[NSThread isMainThread]", options)
if is_main_thread.GetError().Fail() or is_main_thread.GetValueAsUnsigned() != 1:
raise RuntimeError("Refusing to run UIKit probe outside the main thread")
result = frame.EvaluateExpression(
r'''({
UIView *host = (UIView *)[[NSClassFromString(@"RNUITextView") alloc] init];
@@ -39,14 +46,28 @@ def __lldb_init_module(debugger, _internal_dict):
container.frame = frame;
[container layoutIfNeeded];
CGFloat wide = text.frame.size.width;
auto keptSelection = text.selectedRange;
// Fabric recycles the view: the container keeps its size in the pool,
// and the next paragraph mounts it 30 points wider (a list-item paragraph
// reused by a full-width one) or at the same size.
[host performSelector:@selector(prepareForRecycle)];
frame.size.width = 530;
container.frame = frame;
[container layoutIfNeeded];
CGFloat reusedWider = text.frame.size.width;
[host performSelector:@selector(prepareForRecycle)];
container.frame = frame;
[container layoutIfNeeded];
CGFloat reusedSame = text.frame.size.width;
BOOL ok = text != nil && narrow == 240 && wide == 500 &&
selection.location == text.selectedRange.location &&
selection.length == text.selectedRange.length &&
reusedWider == 530 && reusedSame == 530 &&
selection.location == keptSelection.location &&
selection.length == keptSelection.length &&
selection.location == 12 && selection.length == 9;
[NSString stringWithFormat:@"NATIVE_REFLOW_%@ narrow=%.0f wide=%.0f selection=%lu:%lu",
ok ? @"PASS" : @"FAIL", narrow, wide,
(unsigned long)text.selectedRange.location,
(unsigned long)text.selectedRange.length];
[NSString stringWithFormat:@"NATIVE_REFLOW_%@ narrow=%.0f wide=%.0f recycled=%.0f,%.0f selection=%lu:%lu",
ok ? @"PASS" : @"FAIL", narrow, wide, reusedWider, reusedSame,
(unsigned long)keptSelection.location,
(unsigned long)keptSelection.length];
})''',
options,
)
@@ -15,4 +15,4 @@ xcrun lldb --batch -p "$PASEO_NATIVE_TEXT_REFLOW_PID" \
-o "process detach" -o quit > "$PASEO_NATIVE_TEXT_REFLOW_LOG" 2>&1
grep '^NATIVE_REFLOW_' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
grep -q '^NATIVE_REFLOW_PASS narrow=240 wide=500 selection=12:9$' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
grep -q '^NATIVE_REFLOW_PASS narrow=240 wide=500 recycled=530,530 selection=12:9$' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
@@ -13,6 +13,7 @@ import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
export const test = base.extend<{
startup: { release(): Promise<void>; fail(): Promise<void> };
promptRejection: void;
creation: Awaited<ReturnType<typeof createCreationScenario>>;
promptRetry: ReturnType<typeof createPromptRetryScenario>;
agentRetries: Awaited<ReturnType<typeof retryNextAgentCreation>>;
@@ -65,6 +66,37 @@ export default function contribute(server) {
await client.close();
}
},
promptRejection: async ({ page }, provide) => {
void page;
const client = await connectNewWorkspaceDaemonClient();
const plugin = await copyPluginExample("lifecycle-logger");
const previous = await client.getDaemonConfig();
try {
await writeFile(
path.join(plugin.directory, "index.server.ts"),
`
export default function contribute(server) {
return server.before("agent.create", ({ request }) => ({
...request,
config: {
...request.config,
model: "e2e-fast-stream",
featureValues: { ...request.config.featureValues, mockPromptRejections: 1 },
},
}));
}
`,
);
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(plugin.directory);
await provide();
} finally {
await client.removePlugin("lifecycle-logger");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await plugin.cleanup();
await client.close();
}
},
creation: async ({ page }, provide) => {
const creation = await createCreationScenario(page);
try {
@@ -98,7 +98,6 @@ async function createCodexRestoreJourney(page: Page, client: SeedDaemonClient, i
settings: { modeId: "full-access", thinkingOptionId: "low" },
title: "Codex worktree restore reproduction",
initialPrompt: `Reply with exactly ${REPLY} and nothing else. Do not use tools.`,
background: true,
}),
);
agentId = agent.agentId;
@@ -182,6 +182,47 @@ export async function createCreationScenario(page: Page) {
await this.expectWorkspaceReadyBeforeAgentCompletion();
expect((await project.client.fetchAgents()).entries).toHaveLength(0);
},
async expectCreatedAgentError() {
await requests.settled();
await expect(
page
.getByTestId("assistant-message")
.filter({ hasText: "[System Error] Requested mock prompt rejection", visible: true }),
).toBeVisible();
await expect(
page
.locator('[data-testid^="workspace-tab-agent_"][aria-selected="true"]')
.filter({ visible: true }),
).toBeVisible();
await expect(
page.locator('[data-testid^="workspace-tab-draft_"]').filter({ visible: true }),
).toHaveCount(0);
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEditable();
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
},
async expectPromptBeforeError(prompt: string) {
const messages = page
.getByTestId("user-message")
.or(page.getByTestId("assistant-message"))
.filter({ visible: true });
await expect(messages).toHaveCount(2);
await expect(messages.nth(0)).toContainText(prompt);
await expect(messages.nth(1)).toContainText("[System Error]");
await expect(messages.nth(1)).toBeInViewport();
},
async reloadAgent() {
await page.reload();
await expect(page.getByText("Updating messages", { exact: true })).toHaveCount(0);
await this.expectCreatedAgentError();
},
async expectAssistantReply() {
await expect(
page
.getByTestId("assistant-message")
.filter({ visible: true, hasNotText: "[System Error]" })
.first(),
).toBeVisible();
},
async expectStartupFailure() {
await expect(page.getByText(/Creation startup failed for test/).first()).toBeVisible();
},
+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,
});
}
@@ -66,6 +66,7 @@ export interface SeedDaemonClient {
worktreeSlug?: string;
};
title?: string;
background?: boolean;
}): Promise<{
workspace: SeedWorkspaceDescriptor | null;
error: string | null;
@@ -223,6 +224,7 @@ export interface SeededWorkspace {
export async function seedWorkspace(options: {
repoPrefix: string;
title?: string;
background?: boolean;
port?: number;
/** Repo fixture options; only applies to git projects (the default). */
repo?: Parameters<typeof createTempGitRepo>[1];
@@ -238,6 +240,7 @@ export async function seedWorkspace(options: {
const created = await client.createWorkspace({
source: { kind: "directory", path: project.path },
title: options.title,
background: options.background,
});
if (!created.workspace) {
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
@@ -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);
@@ -58,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.5",
"version": "0.11.2",
"private": true,
"main": "index.ts",
"scripts": {
@@ -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");
+92 -38
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]);
@@ -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>
+1
View File
@@ -792,6 +792,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
detail={data.detail}
cwd={context.cwd}
metadata={data.metadata}
agentMessage={data.agentMessage}
isLastInSequence={isLastInSequence}
onOpenFilePath={handleToolCallOpenFile}
maxDetailHeight={maxDetailHeight}
-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."] },
@@ -24,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: "-",
@@ -66,7 +67,7 @@ export function createAssistantSelectionClipboardContent(
return null;
}
const range = selection.getRangeAt(0);
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.
*
@@ -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) });
}
+13 -9
View File
@@ -1,3 +1,4 @@
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import { ASSISTANT_IMAGE_DEFAULT_ASPECT_RATIO } from "@/utils/assistant-image-metadata";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { TaskListRow } from "@/components/task-list-row";
@@ -74,6 +75,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";
@@ -629,6 +631,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);
@@ -644,10 +647,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) : ""),
@@ -3022,6 +3023,7 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
}
interface ToolCallProps {
agentMessage?: AgentMessage;
toolName: string;
args?: unknown;
result?: unknown;
@@ -3041,6 +3043,7 @@ interface ToolCallProps {
}
export const ToolCall = memo(function ToolCall({
agentMessage,
toolName,
args,
result,
@@ -3081,6 +3084,7 @@ export const ToolCall = memo(function ToolCall({
const presentation = useMemo(
() =>
buildToolCallPresentation({
agentMessage,
toolName,
status,
error: error ?? null,
@@ -3089,7 +3093,7 @@ export const ToolCall = memo(function ToolCall({
cwd,
resolveIcon: resolveToolCallIcon,
}),
[toolName, status, error, effectiveDetail, metadata, cwd],
[toolName, status, error, effectiveDetail, metadata, cwd, agentMessage],
);
const handleOpenFile = useMemo(() => {
const openFilePath = presentation.openFilePath;
@@ -3105,7 +3109,7 @@ export const ToolCall = memo(function ToolCall({
toolName,
displayName: presentation.displayName,
summary: presentation.summary,
detail: effectiveDetail,
detail: presentation.detail,
errorText: presentation.errorText,
icon: presentation.icon,
showLoadingSkeleton: presentation.isLoadingDetails,
@@ -3117,12 +3121,12 @@ export const ToolCall = memo(function ToolCall({
shouldRenderInline,
openToolCall,
toolName,
presentation.detail,
presentation.displayName,
presentation.summary,
presentation.errorText,
presentation.icon,
presentation.isLoadingDetails,
effectiveDetail,
]);
useEffect(() => {
@@ -3158,7 +3162,7 @@ export const ToolCall = memo(function ToolCall({
return (
<ToolCallDetailsContent
toolName={toolName}
detail={effectiveDetail}
detail={presentation.detail}
errorText={presentation.errorText}
maxHeight={maxDetailHeight}
showLoadingSkeleton={presentation.isLoadingDetails}
@@ -3167,7 +3171,7 @@ export const ToolCall = memo(function ToolCall({
}, [
shouldRenderInline,
toolName,
effectiveDetail,
presentation.detail,
presentation.errorText,
presentation.isLoadingDetails,
maxDetailHeight,
@@ -63,11 +63,7 @@ import {
type SidebarWorkspacePlacement,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import {
hasActiveSidebarLabelFilter,
useSidebarViewStore,
type SidebarGroupMode,
} from "@/stores/sidebar-view-store";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges";
import {
ContextMenu,
@@ -1919,9 +1915,6 @@ export function SidebarWorkspaceList({
const onToggleWorkspacePin = useSidebarWorkspacePinController();
const getPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.getPinnedWorkspaceOrder);
const setPinnedWorkspaceOrder = useSidebarOrderStore((state) => state.setPinnedWorkspaceOrder);
const hasActiveLabelFilter = useSidebarViewStore((state) =>
hasActiveSidebarLabelFilter(state.labelFilter),
);
const handlePinnedWorkspaceReorder = useCallback(
(reorderedWorkspaces: SidebarWorkspacePlacement[]) => {
const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey);
@@ -1953,11 +1946,10 @@ export function SidebarWorkspaceList({
// this whole subtree, which unmounted the header — and the header is where the display menu's
// trigger lives, so filtering the last row away closed the menu you were filtering from.
//
// Only the label filter can get here. The project filter resolves against the projects it can
// see and falls back to "all projects" when nothing matches, so it either keeps at least one
// project or is not applied at all — it can narrow this list but never empty it.
const sidebarFilterEmpty =
hasActiveLabelFilter && hasProjectsBeforeFilter && projects.length === 0;
// Background visibility can also hide the last project. Keep the header mounted
// whenever a local filter empties an otherwise populated directory, so users can
// reach the display menu to reveal their work again.
const sidebarFilterEmpty = hasProjectsBeforeFilter && projects.length === 0;
// Project mode is the one that keeps its project headers; every other grouping mode is a flat
// list of grouped rows, so a new mode lands in the grouped branch rather than silently in this
@@ -301,6 +301,13 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
sheetTitle={t("sidebar.display.heading")}
testID="sidebar-display-preferences-content"
>
<MenuItem
selected={preferences.showBackground}
closeOnSelect={false}
onSelect={preferences.toggleBackground}
>
{t("sidebar.display.showBackground")}
</MenuItem>
<MenuSubTrigger
id="grouping"
value={t(GROUPING_LABEL_KEYS[preferences.grouping])}
@@ -16,6 +16,8 @@ import { DEFAULT_SIDEBAR_ROW_ITEMS, type SidebarRowItem, type SidebarRowItems }
export type SidebarTrailingChoice = Exclude<SidebarWorkspaceTrailing, "none">;
export interface SidebarDisplayPreferences {
showBackground: boolean;
toggleBackground: () => void;
grouping: SidebarGroupMode;
setGrouping: (mode: SidebarGroupMode) => void;
titleSource: WorkspaceTitleSource;
@@ -48,6 +50,8 @@ export interface SidebarDisplayPreferences {
* this for a value and set it; where it lands is this module's problem.
*/
export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
const showBackground = useSidebarViewStore((state) => state.showBackground);
const toggleBackground = useSidebarViewStore((state) => state.toggleBackground);
const grouping = useSidebarViewStore((state) => state.groupMode);
const setGrouping = useSidebarViewStore((state) => state.setGroupMode);
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
@@ -104,6 +108,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
return useMemo(
() => ({
showBackground,
toggleBackground,
grouping,
setGrouping,
titleSource: workspaceTitleSource,
@@ -125,6 +131,8 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
clearLabelFilter,
}),
[
showBackground,
toggleBackground,
grouping,
setGrouping,
workspaceTitleSource,
@@ -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,7 +55,9 @@ export function SidebarModelProvider({
active?: boolean;
children: ReactNode;
}) {
const { t } = useTranslation();
const list = useSidebarWorkspacesList({ enabled: active });
const showBackground = useSidebarViewStore((state) => state.showBackground);
const groupMode = useSidebarViewStore((state) => state.groupMode);
const labelFilter = useSidebarViewStore((state) => state.labelFilter);
const projectFilters = useSidebarViewStore((state) => state.projectFilters);
@@ -105,9 +108,12 @@ export function SidebarModelProvider({
workspaces: [...workspaceEntriesByKey.values()],
projectFilters: resolvedProjectFilters,
});
const filtered = filterWorkspacesByLabels({ workspaces: byProject, ...labelFilter });
const filtered = filterWorkspacesByLabels({
workspaces: byProject.filter((workspace) => showBackground || !workspace.background),
...labelFilter,
});
return new Map(filtered.map((workspace) => [workspace.workspaceKey, workspace]));
}, [labelFilter, resolvedProjectFilters, workspaceEntriesByKey]);
}, [showBackground, labelFilter, resolvedProjectFilters, workspaceEntriesByKey]);
const visibleWorkspaceKeys = useMemo(
() => new Set(filteredWorkspaceEntriesByKey.keys()),
[filteredWorkspaceEntriesByKey],
@@ -122,16 +128,19 @@ export function SidebarModelProvider({
const included = new Set(resolvedProjectFilters);
projects = projects.filter((project) => included.has(project.viewKey));
}
if (hasActiveLabelFilter) {
if (hasActiveLabelFilter || !showBackground) {
projects = projects.flatMap((project) => {
const workspaces = project.workspaces.filter((workspace) =>
visibleWorkspaceKeys.has(workspace.workspaceKey),
);
return workspaces.length > 0 ? [{ ...project, workspaces }] : [];
return workspaces.length > 0 || (!hasActiveLabelFilter && project.workspaces.length === 0)
? [{ ...project, workspaces }]
: [];
});
}
return projects;
}, [
showBackground,
hasActiveLabelFilter,
hasActiveProjectFilter,
resolvedProjectFilters,
@@ -150,6 +159,7 @@ export function SidebarModelProvider({
pinnedCollapsed,
collapsedProjectKeys,
collapsedWorkspaceGroupKeys,
t,
}),
[
collapsedProjectKeys,
@@ -161,6 +171,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(", ");
@@ -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) {
@@ -176,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)
) {
@@ -50,7 +50,7 @@ import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache";
import { useProviderSubagentStore } from "@/subagents/provider-store";
import { resyncProviderSubagents, useProviderSubagentStore } from "@/subagents/provider-store";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -532,9 +532,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
});
const unsubProviderSubagentUpdate = onFeed("agent.provider_subagents.update", (message) => {
if (message.type !== "agent.provider_subagents.update") return;
useProviderSubagentStore.getState().applyUpdate(serverId, message.payload);
const unsubProviderSubagentUpdate = feeds.subscribe({
snapshot: () => {
void resyncProviderSubagents(client, serverId);
},
update: (message) => {
if (message.type !== "agent.provider_subagents.update") return;
useProviderSubagentStore.getState().applyUpdate(serverId, message.payload);
},
});
const unsubCheckoutStatusUpdate = onFeed("checkout_status_update", (message) => {
@@ -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;
@@ -27,6 +27,7 @@ export interface SidebarWorkspacePlacement {
workspaceDirectory?: string;
projectKind: WorkspaceStructureProject["projectKind"];
workspaceKind: WorkspaceDescriptor["workspaceKind"];
background?: boolean;
name: string;
}
@@ -164,6 +165,7 @@ export function createSidebarWorkspaceEntry(input: {
input.workspace.worktreeSlug ?? shortenPath(input.workspace.workspaceDirectory),
projectKind: input.workspace.projectKind,
workspaceKind: input.workspace.workspaceKind,
background: input.workspace.background,
name: input.workspace.name,
title: input.workspace.title ?? null,
pinnedAt: input.workspace.pinnedAt,
+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([]);
});
+59
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,7 +1138,16 @@ export const ar: TranslationResources = {
},
},
sidebar: {
statusGroupAccessibility: "مجموعة {{label}}",
statusBucket: {
needsInput: "تحتاج إدخالاً",
failed: "فشل",
readyToReview: "جاهزة للمراجعة",
working: "قيد العمل",
done: "تم",
},
display: {
showBackground: "إظهار مساحات العمل في الخلفية",
trigger: "تفضيلات العرض",
heading: "العرض",
grouping: {
+60 -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,7 +1146,16 @@ export const en = {
},
},
sidebar: {
statusGroupAccessibility: "{{label}} group",
statusBucket: {
needsInput: "Needs input",
failed: "Failed",
readyToReview: "Ready to review",
working: "Working",
done: "Done",
},
display: {
showBackground: "Show background",
trigger: "Display preferences",
heading: "Display",
grouping: {
@@ -2104,7 +2163,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…",
+63 -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,7 +1174,16 @@ 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: {
showBackground: "Mostrar espacios en segundo plano",
trigger: "Preferencias de visualización",
heading: "Visualización",
grouping: {
File diff suppressed because it is too large Load Diff
+59
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,7 +1151,16 @@ export const ja: TranslationResources = {
},
},
sidebar: {
statusGroupAccessibility: "{{label}} グループ",
statusBucket: {
needsInput: "入力待ち",
failed: "失敗",
readyToReview: "レビュー待ち",
working: "実行中",
done: "完了",
},
display: {
showBackground: "バックグラウンドを表示",
trigger: "表示設定",
heading: "表示",
grouping: {
+59
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,7 +1145,16 @@ export const ko: TranslationResources = {
},
},
sidebar: {
statusGroupAccessibility: "{{label}} 그룹",
statusBucket: {
needsInput: "입력 필요",
failed: "실패",
readyToReview: "검토 대기",
working: "실행 중",
done: "완료",
},
display: {
showBackground: "백그라운드 표시",
trigger: "표시 설정",
heading: "표시",
grouping: {
@@ -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: "正在安装…",
+61 -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,7 +1164,16 @@ 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: {
showBackground: "Mostrar espaços em segundo plano",
trigger: "Preferências de exibição",
heading: "Exibição",
grouping: {
+59
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,7 +1155,16 @@ export const ru: TranslationResources = {
},
},
sidebar: {
statusGroupAccessibility: "Группа «{{label}}»",
statusBucket: {
needsInput: "Ожидает ввода",
failed: "Ошибка",
readyToReview: "На проверку",
working: "В работе",
done: "Готово",
},
display: {
showBackground: "Показать фоновые пространства",
trigger: "Настройки отображения",
heading: "Отображение",
grouping: {
+63 -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,7 +1130,16 @@ export const zhCN: TranslationResources = {
},
},
sidebar: {
statusGroupAccessibility: "{{label}} 分组",
statusBucket: {
needsInput: "需要输入",
failed: "失败",
readyToReview: "待查看",
working: "运行中",
done: "已完成",
},
display: {
showBackground: "显示后台工作区",
trigger: "显示偏好",
heading: "显示",
grouping: {
@@ -33,6 +33,10 @@ type ProjectListResult = Awaited<ReturnType<DaemonClient["listProjects"]>>;
type AgentFetchResult = Awaited<ReturnType<DaemonClient["fetchAgents"]>>;
class FakeDirectoryClient {
supportsBackgroundWorkspaces(): boolean {
return false;
}
supportsWorkspaceLabels = false;
listWorkspaceLabelsCalls = 0;
fetchAgentsCalls = 0;
@@ -535,6 +535,7 @@ export class DirectorySync {
let subscribe = initialSubscribe;
while (true) {
const query: Parameters<DaemonClient["observeWorkspaces"]>[0] = {
...(client.supportsBackgroundWorkspaces() ? { includeBackground: true } : {}),
sort: [{ key: "activity_at", direction: "desc" }],
page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT },
...(supportsDirectorySync
@@ -634,6 +635,7 @@ export class DirectorySync {
const limit = input.page?.limit ?? PAGE_LIMIT;
const query: Omit<FetchAgentsOptions, "subscribe"> = {
...(input.filter ? { filter: input.filter } : { scope: "active" as const }),
...(client.supportsBackgroundWorkspaces() ? { includeBackground: true } : {}),
sort: AGENT_SORT,
page: cursor ? { limit, cursor } : { limit },
...(!input.filter && cursor === null && this.supportsDirectorySync()
@@ -52,6 +52,9 @@ it("requests the managed connection credential through desktop main without a we
});
class FakeDaemonClient {
supportsBackgroundWorkspaces(): boolean {
return false;
}
private state: ConnectionState = { status: "idle" };
private listeners = new Set<(status: ConnectionState) => void>();
private error: string | null = null;
@@ -3047,6 +3050,34 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId);
});
it("drains a queued message as a steer so a turn the agent just started is not interrupted", async () => {
const host = makeHost({ serverId: "srv_steer_queue_drain" });
const fakeClient = new FakeDaemonClient();
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async () => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: null,
}),
getClientId: async () => "cid_steer_queue_drain",
},
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
sessionStore.setQueuedMessages(
host.serverId,
new Map([["agent", [{ id: "queued", text: "next task", attachments: [] }]]]),
);
store.drainQueuedAgentMessage(host.serverId, "agent");
await fakeClient.waitForSentMessages(1);
expect(fakeClient.sentAgentMessages[0]?.[2]?.activeTurnBehavior).toBe("steer");
sessionStore.clearSession(host.serverId);
});
it("uses legacy GitHub attachments when draining a queue for an old daemon", async () => {
const host = makeHost({ serverId: "srv_legacy_queue_attachment" });
const fakeClient = new FakeDaemonClient();
+5
View File
@@ -2426,6 +2426,11 @@ export class HostRuntimeStore {
}),
encodeImages,
submission: createMessageSubmissionWriter(serverId),
// The queue drains when the agent goes idle, but the agent can start a turn of its own
// in that same moment (a wake-up after background work). Steering folds the message
// into that turn instead of interrupting it; with no turn running, the daemon starts
// a normal one.
activeTurnBehavior: "steer",
});
},
})
@@ -859,3 +859,36 @@ describe("ReplicaCache", () => {
expect(storage.cleanups).toBe(1);
});
});
it("retains semantic agent messages across disk-cache reconstruction", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
const value = timeline();
value.items = [
{
kind: "tool_call",
id: "delivery",
timestamp: new Date(1),
timelineCursor: { epoch: "epoch-1", seq: 12 },
payload: {
source: "agent",
data: {
provider: "codex",
callId: "delivery",
name: "agent_message",
status: "completed",
error: null,
detail: { type: "plain_text", text: "Done" },
agentMessage: {
event: "finished",
sender: { id: "remote::worker", title: "Reviewer" },
text: "Done",
},
},
},
},
];
writer.commitTimeline(SERVER_ID, "agent-1", value);
await writer.flush();
expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toEqual(value);
});
@@ -421,6 +421,7 @@ function serializeAgentToolCall(data: AgentToolCallData): StoredToolCall {
name: data.name,
detail: data.detail,
...(data.metadata ? { metadata: data.metadata } : {}),
...(data.agentMessage ? { agentMessage: data.agentMessage } : {}),
};
switch (data.status) {
case "running":
@@ -584,6 +585,7 @@ function deserializeBuiltinTimelineItem(
error: tool.error,
detail: tool.detail,
...(tool.metadata ? { metadata: tool.metadata } : {}),
...(tool.agentMessage ? { agentMessage: tool.agentMessage } : {}),
},
},
};
+2
View File
@@ -113,6 +113,7 @@ export interface WorkspaceDescriptor {
worktreeSlug?: WorkspaceDescriptorPayload["worktreeSlug"];
projectKind: WorkspaceDescriptorPayload["projectKind"];
workspaceKind: WorkspaceDescriptorPayload["workspaceKind"];
background?: boolean;
name: string;
title?: string | null;
pinnedAt?: string | null;
@@ -150,6 +151,7 @@ export function normalizeWorkspaceDescriptor(
worktreeSlug: payload.worktreeSlug,
projectKind: payload.projectKind,
workspaceKind: payload.workspaceKind,
background: payload.background ?? false,
name: payload.name,
title: payload.title ?? null,
pinnedAt: payload.pinnedAt ?? null,
@@ -32,6 +32,7 @@ function createMemoryStorage(entries: Record<string, string | null>): MemoryStor
describe("sidebar view store", () => {
beforeEach(() => {
useSidebarViewStore.setState({
showBackground: false,
groupMode: "project",
hostFilters: [],
projectFilters: [],
@@ -84,6 +85,7 @@ describe("sidebar view store", () => {
},
}),
).toEqual({
showBackground: false,
groupMode: "status",
hostFilters: [],
projectFilters: [],
@@ -98,6 +100,7 @@ describe("sidebar view store", () => {
hostFilter: "host-a",
}),
).toEqual({
showBackground: false,
groupMode: "status",
hostFilters: ["host-a"],
projectFilters: [],
@@ -112,6 +115,7 @@ describe("sidebar view store", () => {
hostFilters: ["host-a", "host-b"],
}),
).toEqual({
showBackground: false,
groupMode: "status",
hostFilters: ["host-a", "host-b"],
projectFilters: [],
@@ -222,6 +226,7 @@ describe("sidebar view store", () => {
projectFilters: ["project-a", "project-b"],
}),
).toEqual({
showBackground: false,
groupMode: "project",
hostFilters: ["host-a"],
projectFilters: ["project-a", "project-b"],
@@ -231,6 +236,7 @@ describe("sidebar view store", () => {
it("never keeps project filters from state the schema rejects", () => {
expect(migrateSidebarViewState({ projectFilters: "project-a" })).toEqual({
showBackground: false,
groupMode: "project",
hostFilters: [],
projectFilters: [],
+11 -1
View File
@@ -9,7 +9,7 @@ export type SidebarGroupMode = "project" | "status";
const SIDEBAR_VIEW_STORAGE_KEY = "sidebar-view";
const LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY = "sidebar-group-mode";
const SIDEBAR_VIEW_STORE_VERSION = 6;
const SIDEBAR_VIEW_STORE_VERSION = 7;
/**
* The key standing for "this workspace carries no labels at all".
@@ -47,6 +47,7 @@ function toggleFilterEntry(list: readonly string[], key: string): string[] {
interface SidebarViewStoreState {
groupMode: SidebarGroupMode;
showBackground: boolean;
// Empty means "all hosts". A non-empty list pins the sidebar to those hosts.
hostFilters: string[];
/**
@@ -61,6 +62,7 @@ interface SidebarViewStoreState {
*/
projectFilters: string[];
labelFilter: SidebarLabelFilter;
toggleBackground: () => void;
setGroupMode: (mode: SidebarGroupMode) => void;
toggleHostFilter: (serverId: string) => void;
clearHostFilters: () => void;
@@ -74,6 +76,7 @@ interface SidebarViewStoreState {
interface SidebarViewPersistedState {
groupMode: SidebarGroupMode;
showBackground: boolean;
hostFilters: string[];
projectFilters: string[];
labelFilter: SidebarLabelFilter;
@@ -84,6 +87,7 @@ const SidebarLabelFilterSchema = z.object({
labels: z.array(z.string()),
});
const SidebarViewPersistedStateSchema = z.strictObject({
showBackground: z.boolean().optional(),
groupMode: PersistedSidebarGroupModeSchema.optional(),
hostFilters: z.array(z.string()).optional(),
hostFilter: z.string().nullable().optional(),
@@ -122,6 +126,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer
const result = SidebarViewPersistedStateSchema.safeParse(persistedState);
if (!result.success) {
return {
showBackground: false,
groupMode: "project",
hostFilters: [],
projectFilters: [],
@@ -133,6 +138,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer
const legacyGroupMode = readLegacyGroupMode(state);
if (legacyGroupMode) {
return {
showBackground: false,
groupMode: legacyGroupMode,
hostFilters: [],
projectFilters: [],
@@ -141,6 +147,7 @@ export function migrateSidebarViewState(persistedState: unknown): SidebarViewPer
}
return {
showBackground: state.showBackground ?? false,
groupMode: state.groupMode === "status" ? "status" : "project",
hostFilters: readHostFilters(state),
projectFilters: state.projectFilters ?? [],
@@ -178,10 +185,12 @@ export function createSidebarViewStorage(
export const useSidebarViewStore = create<SidebarViewStoreState>()(
persist(
(set) => ({
showBackground: false,
groupMode: "project",
hostFilters: [],
projectFilters: [],
labelFilter: emptyLabelFilter(),
toggleBackground: () => set((state) => ({ showBackground: !state.showBackground })),
setGroupMode: (mode) => set({ groupMode: mode }),
toggleHostFilter: (serverId) =>
set((state) => ({ hostFilters: toggleFilterEntry(state.hostFilters, serverId) })),
@@ -228,6 +237,7 @@ export const useSidebarViewStore = create<SidebarViewStoreState>()(
SidebarViewPersistedStateSchema,
),
partialize: (state) => ({
showBackground: state.showBackground ?? false,
groupMode: state.groupMode,
hostFilters: state.hostFilters,
projectFilters: state.projectFilters,
@@ -1,8 +1,11 @@
import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
import { afterEach, describe, expect, test } from "vitest";
import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
import {
observeProviderSubagentTimeline,
providerSubagentKey,
refreshProviderSubagents,
resyncProviderSubagents,
useProviderSubagentStore,
} from "./provider-store";
@@ -15,6 +18,7 @@ afterEach(() => {
descriptors: new Map(),
timelines: new Map(),
hiddenFromTrack: new Set(),
trackedParents: new Set(),
});
});
@@ -727,3 +731,96 @@ describe("projected child history", () => {
expect(current().lastSeq).toBe(2000);
});
});
describe("resyncing after the update feed resubscribes", () => {
function child(
id: string,
parentAgentId: string,
status: ProviderSubagentDescriptorPayload["status"],
): ProviderSubagentDescriptorPayload {
return {
id,
parentAgentId,
provider: "claude",
title: "Explore",
description: null,
status,
createdAt: "2026-10-07T10:00:00.000Z",
updatedAt: "2026-10-07T10:00:00.000Z",
toolCallId: id,
};
}
function fakeDaemon(lists: Record<string, ProviderSubagentDescriptorPayload[]>) {
const requested: string[] = [];
return {
requested,
lists,
client: {
listProviderSubagents: async (parentAgentId: string) => {
requested.push(parentAgentId);
return {
requestId: "list",
parentAgentId,
subagents: lists[parentAgentId] ?? [],
error: null,
};
},
},
};
}
const status = (parentAgentId: string, subagentId: string) =>
useProviderSubagentStore
.getState()
.descriptors.get(providerSubagentKey(SERVER_ID, parentAgentId, subagentId))?.status;
test("catches up on children that finished or started while updates were not delivered", async () => {
const daemon = fakeDaemon({ [PARENT_ID]: [child("child-1", PARENT_ID, "running")] });
await refreshProviderSubagents(daemon.client, SERVER_ID, PARENT_ID);
expect(status(PARENT_ID, "child-1")).toBe("running");
// The connection dropped: the daemon finished child-1 and ran child-2 without telling us.
daemon.lists[PARENT_ID] = [
child("child-1", PARENT_ID, "completed"),
child("child-2", PARENT_ID, "completed"),
];
await resyncProviderSubagents(daemon.client, SERVER_ID);
expect(status(PARENT_ID, "child-1")).toBe("completed");
expect(status(PARENT_ID, "child-2")).toBe("completed");
});
test("catches up on a parent whose first list failed while disconnected", async () => {
const daemon = fakeDaemon({});
const disconnected = {
listProviderSubagents: async () => {
throw new DaemonConnectionError("Transport not connected");
},
};
await expect(
refreshProviderSubagents(disconnected, SERVER_ID, PARENT_ID),
).rejects.toBeInstanceOf(DaemonConnectionError);
useProviderSubagentStore.getState().applyUpdate(SERVER_ID, {
kind: "upsert",
subagent: child("child-1", PARENT_ID, "running"),
});
// The connection dropped again: the daemon finished child-1 without telling us.
daemon.lists[PARENT_ID] = [child("child-1", PARENT_ID, "completed")];
await resyncProviderSubagents(daemon.client, SERVER_ID);
expect(status(PARENT_ID, "child-1")).toBe("completed");
});
test("lists again only the parents this server was showing", async () => {
const daemon = fakeDaemon({});
await refreshProviderSubagents(daemon.client, SERVER_ID, PARENT_ID);
await refreshProviderSubagents(daemon.client, "server-2", "other-parent");
daemon.requested.length = 0;
await resyncProviderSubagents(daemon.client, SERVER_ID);
expect(daemon.requested).toEqual([PARENT_ID]);
});
});

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