Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 77c4d889c0 chore: bring terminal file-drop fix up to date 2026-11-06 16:20:47 +00:00
paseo-bot[bot]andSebastian Arena 237e784af8 fix(terminal): separate uploaded file paths when pasting
Keep the remote file-drop regression and its test coverage from the contributor change, without introducing new terminal controls. Each uploaded path needs a trailing separator because adjacent quoted paths are one shell word.

Co-authored-by: Sebastian Arena <sebastian@walkofcode.io>
2026-11-06 16:20:22 +00:00
Mohamed Boudra f21699a37a chore(release): reconcile 0.15.3 metadata (#8943) 2026-11-06 23:10:33 +07:00
Mohamed Boudra a9bce9a18d Keep the chat still when an agent's turn ends (#8944)
* Land a turn's reply, completed footer, and tool summary in one frame

The stream handler closed turn liveness as soon as turn_completed arrived,
while the final reply waited in the frame-coalesced reducer queue. For one
commit the turn was finished with no reply: the footer vanished and the
timeline jumped twice, and tool turns folded into their summary later.

The viewed timeline owner now takes stream events whole: a turn boundary
commits the queued content before liveness changes. Running and completed
footers share one fixed content height so the phase switch moves nothing.

* Document the turn-boundary commit and stop racing the re-pin in the turn-open test

The turn-open test scrolled the received message into view with a
programmatic scroll, which leaves the chat following its end. Pressing
the turn summary swaps its icon, and that DOM change re-pins the chat to
the end between pointerdown and pointerup. The summary had moved 14 px,
which left the pointer 2 px inside the button; with the completed footer
now 2 px taller, it moved 16 px and the click landed on the reply below.
The message's fade and Show more are already on screen at the end, so
the test no longer scrolls.
2026-11-06 23:10:29 +07:00
paseo-bot[bot] 73448d483e test(app): scope command-center stability checks to the panel (#8927)
Document-wide layout shifts could fail a stable file-search panel. Count only shifts attributed to the panel or its descendants, and exercise both unrelated and in-panel movement in the existing browser journey.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 15:55:09 +00:00
Mohamed BoudraandClaude 161076a853 Show loading and errors in the slash command menu, and stop defaulting to /exit (#8905)
* feat(app): show loading and errors in the composer command menu

Rows are single lines with a kind icon, the matched part of the name, the
argument hint, the description and a source tag. The detail card is gone.

The menu used to stay hidden while commands loaded and then show
"Error: ..." over a list whose options Enter could still pick, so a failed
load plus Enter archived the agent through a hidden /exit. Loading and
error are now status rows with no options; the error row names the reason
and offers Retry. Loading appears after 300ms so a warm cache paints the
results first, as before.

An empty query used to select /exit. Provider commands now come first, so
the bottom row the input selects is a provider command.

useAutocomplete derives the selection during render. When results replace
the loading row, the selection is valid in the same render, so the popover
stays mounted at its anchor instead of remounting.

Claude Code's `__`-prefixed commands (/__remote-workflow) only run in its
own server-launched sessions, so the Claude provider no longer lists them.

* fix(app): never default the command menu to a Paseo action

An arrow-key choice was tied to the query it was made for, so returning to
that query brought it back: arrow to /exit on `/`, type `com`, delete it, and
Enter archived the agent. A choice now clears whenever the query changes.

An empty query still selected /exit when the agent had no provider or plugin
commands, because Paseo's actions were the only entries. With nothing else to
list, an empty query now lists nothing and waits for a search; typing `/ex`
still finds /exit.

* fix(app): sort empty-query commands without toSorted

Hermes has no Array.prototype.toSorted, so typing `/` threw "undefined is
not a function" on iOS and Android and the app showed its error screen.
Browsers and Node have it, which is why web and unit tests passed.

* test(app): cover the stale command choice in Playwright and wait out the loading row on devices

The renderHook test mounted React under JSDOM, which docs/testing.md forbids.
The Playwright test drives the same sequence in a real browser and fails
without the reset in useAutocomplete: Enter chose the stale /exit.

The composer-keyboard harnesses treated a visible popover as a ready list.
The popover now shows a loading row first, and a cold Claude command list
takes several seconds, so both harnesses wait for the rows.

* fix(app): size the command menu loading spinner to the row icons

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-11-06 22:15:10 +07:00
paseo-bot[bot]anda-sh 14845d3ff3 Keep ACP mode selections valid after config updates (#8912)
* fix(acp): preserve session mode identity across config updates

* test(acp): verify mode identity through public sessions

Exercise initialization, queued notifications, mode selection, and rejection over stdio instead of replacing private connection state.

Co-authored-by: a-sh <846704+a-sh@users.noreply.github.com>

---------

Co-authored-by: a-sh <846704+a-sh@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 15:10:43 +00:00
Mohamed Boudra 616f5e155c fix(app): show branch loading and fetch errors (#8938)
* fix(app): distinguish branch loading from empty results

Preserve branch query pending and error states through the switcher so first open does not report missing branches before the response. Show request failures with a retry action.

* fix(app): keep branch retry progress and keyboard focus

Verify failed cached refresh and keyboard retry through the real browser and daemon. Keep focus in the footer while Retry is disabled so repeated Enter cannot select a cached branch.

* fix(app): preserve picker interaction when retrying branch loads

Use the existing close-and-reopen retry behavior with clear feedback rather than introducing an action that competes with native list keyboard ownership.
2026-11-06 21:35:20 +07:00
Mohamed Boudra 471db4bdb5 Keep workspace tabs predictable and clear attention on deliberate focus (#8935)
* fix(app): preserve workspace tabs and acknowledge deliberate agent focus

Workspace navigation no longer chooses an attention agent. Preserve repeated focus actions so an already selected chat can be acknowledged, and stop consuming attention on departure.

* fix(app): retain deliberate focus until fresh agent data arrives

Connection readiness permits a pending focus acknowledgement without creating a new focus action. Keep that intent across client changes and wait for the current timeline synchronization generation before consuming it.

* fix(app): preserve reconnect feedback during attention acknowledgement
2026-11-06 20:55:16 +07:00
Mohamed Boudra 68fc16b855 fix(terminal): run terminal workers without process identity queries (#8818)
* fix(terminal): run terminals when process identity cannot be read

On some Windows machines Windows PowerShell cannot run Get-CimInstance, or
cannot be started at all. The terminal worker refused to start without its own
birth time and command line, so those machines had no terminals.

Process identity only authorizes signals. The worker now starts anyway and
records its identity as unavailable, and programs without a birth time. Cleanup
never signals what it cannot identify. It asks such a worker to shut down over
the socket the secret authenticates, and reports survivors as unconfirmed.

So records of crashed workers do not block launches, the process table falls
back to process.kill(pid, 0) when the query fails. When a live pid cannot be
identified, a refused connection on the worker's socket marks it as another
process, because a worker listens until it exits.

* fix(terminal): ask unidentified workers to shut down on every cleanup

Retries of an unconfirmed cleanup and startup cleanup run without a grace
period, which skipped the shutdown request. For a worker whose identity is
unavailable that request is the only way to stop it, so it is now always sent.

A gone program recorded without a birth time is no longer walked through the
process table: the table was unreadable when it started, and the walk only kept
the cleanup unconfirmed.

Also test the socket probe on every platform, restore a test of the daemon
starting when the worker cannot, and keep one pid liveness helper.

* test(terminal): fail the worker launch the same way on every platform

Windows opens a directory sitting at the launcher's log path, so the
worker started there and the #8810 test never saw a failed launch. An
invalid protocol override makes the worker exit before it writes its
record on every platform.

* fix(terminal): identify workers through their recorded sockets

* fix(terminal): authenticate legacy sockets before forced cleanup

* test(terminal): exercise closing records without process queries

* test(terminal): identify stale lifecycle records by their sockets

* refactor(terminal): drop the unreleased unavailable-identity record shape

No release wrote identity: { unavailable }; it existed only between
commits of this change. Records keep reading the identity workers up to
0.15.1 wrote, and nothing consults it.
2026-11-06 20:20:18 +07:00
Mohamed Boudra ae157dbfb6 Even out chat footer spacing and show the Changes base only on worktrees (#8934)
* fix(app): space the turn footer like the rest of the timeline

The footer sat 13px under the last line of text while the text sat about
21px under a tool or thinking row, so the footer looked glued to the
message. The message drops its bottom padding when a footer follows, so the
footer's top margin alone sets that gap; 20 makes the centre-to-centre
distance match the tool-to-text gap.

* fix(app): show the Changes base only on Paseo worktrees

The header rendered "branch -> base" whenever checkout status carried a
baseRef, and the daemon sends one for every git checkout, so plain
checkouts showed "-> main" with no way to change it. Only Paseo worktrees
store a base, so only they show it. The arrow is extra muted so it reads as
a separator, and the base no longer shrinks, so a long branch name truncates
instead of the base.

* fix(app): let a long Changes base truncate instead of overflowing
2026-11-06 20:20:11 +07:00
geoqiao dc51a3b6a9 fix(app): preserve plugin-rendered replies when collapsing turns (#8811)
* fix(app): preserve plugin-rendered replies when collapsing turns

* test(app): exercise plugin reply folding in the browser
2026-11-06 13:12:44 +00:00
77798b2e46 Show and stop agent background work (#6220)
* feat(server): add an in-memory store for agent background work

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

* feat(claude): report background tasks as background work

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

* feat(server): track agent background work in the agent manager

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

* feat(protocol): add agent background work messages

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

* feat(server): serve and push agent background work

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

* feat(client): expose agent background work on agent handles

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

* docs(sdk): document agent background work

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

* test(claude): cover background shells in background work end to end

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

* fix(client): resync background work subscriptions on every snapshot

subscribe() now lists the agent's background work each time the event
observation is established, so the listener starts with the current list
and catches up after a reconnect. A list answered after a newer update
is dropped.

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

* fix(server): answer background work lists without loading dormant agents

Background work lives only in memory, so a stored agent that is not
loaded has none. The list handler now answers [] from storage instead of
resuming the agent, and still rejects unknown, internal, and archived
agents. A new test pins that old apps never receive
agent.background_work.update.

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

* fix(server): read Claude background tasks defensively

The user's claude binary can differ from the SDK typings, so a task's
description is read as unknown and an empty or missing one becomes null.
Drop the monitor kind: Claude Monitor watches report as local_bash, so
kind is "shell" or "other" today. The store now copies only the wire
fields of each input.

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

* test(server): prove internal background work was emitted before checking globals

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

* docs(sdk): describe background work resync and feature gating

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

* test(claude): cover background work reported by a retired query

The daemon stops reading a Claude query it no longer owns, so work that a
retired process still had queued cannot reach the background work list.

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

* fix(client): let background work subscribers handle a failed list read

When the list read that follows each subscription snapshot failed, the
error was only logged and the caller got neither the list nor a failure.
subscribe() now takes an optional onError that receives it; without one
the error is still logged. The read is not retried, since the next
reconnect reads the list again.

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

* feat(agents): show background work as active

Keep background activity visible after a foreground turn ends while preserving independent send and interrupt behavior.

* fix(agents): protect background work subscriptions

Keep internal work out of public updates and restore observation after a subscription failure.

* feat(agents): show Codex and OpenCode background work

Keep provider-owned shell activity visible after a turn ends while leaving foreground turn controls independent.

* feat(agents): stop individual background work

Wait for provider confirmation so stopping one background item leaves sibling work and foreground turn controls independent.

* refactor(agents): name background stop parameters

* test(website): isolate browser tests from live metadata

* test(agents): clarify background stop journeys

* fix(sdk): report background work subscription failures

Report setup failures so subscribers can replace an observation that the host released.

* fix(app): restore background work after reconnect

Wait for the host handshake before selecting the retained client so an offline pane starts observing when the connection returns.

* fix(agents): retain background shells across rewind

Preserve known live shell ownership when conversation history is rewound, and revoke it when the provider runtime exits.

* fix(website): launch test seeder across platforms

Invoke Wrangler through Node so local website test startup does not depend on an executable npm command shim.

* feat(agents): clarify background task details and tracks

Keep launch descriptions and commands distinct so background work remains identifiable, and allow composer tracks to scroll when space is limited.

* fix(agents): preserve active background work during archive

Keep background-running children out of bulk cleanup and warn before an individual archive. Preserve first taps on composer tracks while the keyboard is open.

* fix(codex): retain background terminals across rewind

Keep live terminal inventories attached to their originating threads so a fork does not hide or misroute their stop actions.

* fix(codex): scope background terminal identities to threads

Prevent commands on separate threads with the same provider process ID from sharing a stop target.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-11-06 19:49:49 +07:00
paseo-bot[bot] c8bd4717c9 test(server): wait for Git ref observation before injecting events (#8924)
Zero in-flight fetches also describes observation that has not started. Wait for the first fetch to start and settle, and exercise delayed watcher setup so missing callbacks cannot silently skip the test events.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 12:25:42 +00:00
Mohamed Boudra 6c1b48d296 Archive a workspace from its sidebar row, with undo (#8904)
* feat(app): archive a workspace from its sidebar row and undo it

Hovering a workspace row on desktop shows an archive button next to the
row menu. Archiving shows a toast with Undo and a Cmd/Ctrl+Z shortcut so a
mis-click costs one keystroke instead of a trip through History.

The daemon serializes archive and undo per workspace across every
connection. An undo that lands while an archive is still tearing down
aborts it at the next checkpoint and restores only the agents that archive
took down; deleting the worktree directory is the point of no return, after
which undo recreates the worktree.

* fix(server,app): close archive and undo races found in review

- An archive request now claims the workspace's archive queue before
  looking up the workspace, so an undo sent right behind it cancels it
  instead of running first and losing to the archive.
- A repeat archive of an already archived workspace no longer wipes the
  agents the earlier archive saved for undo.
- Undo no longer brings back an agent that was archived before the
  workspace archive and only loaded to read its history.
- Each local archive or undo request owns its own intent token, so a
  late response to an earlier request cannot release a newer request's
  hold on the row. An archive canceled by another client's undo puts the
  row back.
- The undo failure toast shows translated copy; the cause goes to the log.

* fix(app): keep a workspace's layout when another client restores it during the undo window

The undo window purged tabs and layout whenever it closed after a
finished archive. If another client restored the workspace before this
client's toast closed, the restored workspace lost its saved
arrangement. The archive now commits only if the workspace is still gone
when the window closes.

* fix(app): show the archived toast's Undo as a button

* fix(app): keep a workspace's agent tabs when it is opened while its undo runs

* fix(app): keep a row whose undo is running when a directory snapshot omits it
2026-11-06 16:16:11 +07:00
paseo-bot[bot]andluke 1a626230d9 Keep clients current when parent archive detaches an unloaded child (#8903)
* fix(server): retain archived parent provenance on detach

* fix(server): publish stored child detach updates on parent archive

Keep subscribed clients current when an unloaded child survives its parent archive. Narrow the contributor change to the missing update; archived-parent provenance remains a separate feature.

Co-authored-by: luke <luopeihuan@gmail.com>

* test(server): clean up the detach fixture static directory

Keep the test daemon static directory inside the fixture root so cleanup removes all test-owned directories.

* fix(server): retain runtime settings in stored agent updates

Parent archive publishes stored survivors through the manager. Preserve their saved runtime model and thinking settings so the update matches stored fetches and subscription filters.

---------

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 09:16:01 +00:00
Mohamed Boudra 4b285a113d Space the touch composer toolbar by what it paints (#8909)
* feat(app): tighten the touch composer toolbar onto the text column

On touch, the toolbar's icon-only glyphs were 20pt muted outlines at a 1.5px
stroke in loosely spaced slots, and the row sat on a flat -6 margin, so the
attach + and the trailing control floated inside the text column. Touch now
places 44pt slots edge to edge, draws icon-only glyphs at 24pt with a 2px
foreground stroke, keeps label-paired glyphs at 20pt, and runs the context
ring at 26pt with a 3px stroke. The row overhangs into the composer padding
by each end control's ink inset (composerToolbarOverhang), so the + ink and
the trailing glyph or send/stop disc sit on the text column's edges.
Pointer sizing is unchanged.

The folded model control now drops its thinking label before the model
label truncates, and the model label truncates on a word boundary
("Ten second…") instead of leaving a space before the ellipsis. Both are
decided from measured widths. The web reports layout widths rounded to whole
pixels, so a tie counts as not fitting.

* fix(app): measure replacement model labels instead of hiding them

The folded model label and TailEllipsisText kept their measuring views mounted
when the label text changed, while discarding the cached widths. A retained
measuring node whose size does not change emits no new layout event on web or
native, so after switching to a model whose label (or a shortened prefix of it)
has the same width, the widths stayed unknown and the label stayed transparent.
Keying each measurer by what it measures remounts it, so every width is
measured again.

Also move the label width sweep into a named helper so the test body states
the expected label order.

* feat(app): space the touch composer toolbar by what it paints

- Lay each control out at its painted width, 20pt apart, with a disc-sized circle that is both its frame and its fully rounded pressed highlight. Pickers are 36pt pills.
- Match the header icons: 20pt glyphs at 1.5px, an 18pt context ring at 2px.
- Always end the row in a disc (voice mode, send or stop) so it does not shift, nested into the composer's corner, with a concentric 26pt corner radius on touch.
- Stop the picker row's clip from flattening the picker highlight's left edge.
- Budget the density ladder on laid-out widths.

* fix(app): disable the stand-in send disc and keep form model fields in their box

- An empty new chat's stand-in Send is disabled until there is content, as in fullscreen.
- A custom model selector trigger, such as the schedule form's field, drops the toolbar slot's margins.
2026-11-06 15:56:09 +07:00
Mohamed BoudraandDavid Greenspan 4281372bee fix(app): keep the chat readable with the keyboard open on iOS 26+ (#8918)
iOS 26 adds a scroll edge effect, a blur at each edge of every scroll view,
and places it in the scroll view's own coordinates without applying
transforms. The keyboard lifts the chat with a translateY, so on iPhones the
blur lands over the readable conversation and stays until the next scroll.
The app draws its own header fade, so the app's ScrollView component class,
which backs every React Native ScrollView, hides all four edge effects.

Co-authored-by: David Greenspan <155814+dgreensp@users.noreply.github.com>
2026-11-06 15:14:23 +07:00
Mohamed Boudra e79e826e23 Stop re-rendering every chat message on each agent update (#8916)
* fix(app): keep unchanged agent fields when an agent update is accepted

* fix(app): share unchanged agent fields at the store write, not in the update policy
2026-11-06 14:48:10 +07:00
Mohamed Boudra 4928500e1f fix(app): keep chat rows their size while their tab is hidden on Android (#8911) 2026-11-06 14:08:09 +07:00
Mohamed Boudra 1e2ab3d596 Keep the first prompt on screen when a new agent starts on Android (#8898)
* refactor(app): stop resetting the chat timeline when its agent id changes

A mounted timeline only ever shows one agent: every host keys its panel by tab id,
and a tab keeps its id across a target change only when a draft becomes the agent
it created. The per-agent resets inside the timeline therefore never had a different
agent to reset for. They only fired at that create handoff, where they hid and
re-placed a timeline that was already on screen.

Delete them: LegendList's dataKey and the placed agent id, the web viewport key,
the bottom anchor's resetForAgent, the history window's agent id, and the
expansion and path-opener resets in the stream view.

* fix(app): keep one chat timeline across the draft-to-agent handoff

A draft tab and the agent its first prompt creates rendered two different panels:
DraftPanel mounted its own dock and list, and the retarget swapped it for AgentPanel's.
On Android, with the LegendList timeline, the swap blanked the first turn and re-placed it.

Render both from the agent panel. A draft supplies its timeline from the pending create
in the create-flow store (composer/draft/timeline.ts) and its composer in the composer
slot. The retarget then re-renders the same stream view with the agent's timeline, so the
list instance and its measured rows stay mounted. Agent gating is unchanged.

Float the compact header over draft tabs as it already floats over agent tabs. The
header sat in flow above a draft and over an agent, so the list viewport grew 92 dp at
the retarget and the turn moved.

The handoff E2E now fails if the first row disappears, hides, or shifts down while the
draft becomes the agent.

* fix(app): load subagents and tasks only for an agent, not a draft tab
2026-11-06 13:38:10 +07:00
paseo-bot[bot] 2716bd96de test(terminal): observe original process completion after exit (#8892)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-06 06:27:04 +00:00
Mohamed Boudra 5abfdf7107 Record workspace creation parentage (#8890)
* feat(workspaces): record workspace creation parentage

Retain the caller workspace as informational ancestry for future sidebar nesting, independently of workspace placement and lifecycle.

* fix(app): preserve workspace parentage in replica cache
2026-11-06 10:55:56 +07:00
Mohamed Boudra 6355b339c8 fix(app): paint a smaller send and stop disc inside the touch hit frame (#8891)
Under touch sizing the composer's send and stop buttons filled their whole
44pt hit frame with color, so they outweighed every unpainted control in the
toolbar. The trigger keeps the 44pt frame and now paints a 36pt disc
(TOOLBAR_DISC_SIZE) centered inside it. Pointer sizing paints 28 inside 28, so
desktop is unchanged. The voice button's recording fill follows the same rule.
2026-11-06 10:28:42 +07:00
Mohamed Boudra 8b9c42c266 Keep every sidebar footer action reachable in a narrow sidebar (#8885)
* fix(app): move sidebar footer actions that don't fit behind a caret

Touch sizing makes each footer icon a 44pt target, so five of them plus
the row's padding need 236pt. A docked sidebar can be 200pt and a fold
column about as narrow, so Settings was cut off at the sidebar's edge.

The row now measures itself and keeps as many full-size buttons as fit;
the rest move behind a caret that opens a menu-engine popover. Help and
Usage leave first, Settings and Add project last, and Hosts and Help
open as pages of the overflow menu rather than second popovers.

* fix(app): keep the sidebar footer empty until it has measured

Laying the footer out before its first onLayout put all five buttons in
the row, so a narrow sidebar painted Settings clipped for a few frames
before the caret replaced it. The unmeasured row now places nothing and
holds its height, so the first painted row is already the measured one.

* fix(app): keep the footer overflow menu on a narrow, short screen

The overflow menu asked for 240pt, and edge clamping can move a surface
but not shrink it, so on a 220pt screen its right edge and submenu
arrows hung off the display. Anchored surfaces now cap their width to
the display inside the same edge padding that clamping uses.

The Hosts page lists every host, so the overflow menu is scrollable:
on a short screen the last hosts and Add host were below the fold with
no way to reach them.
2026-11-06 10:28:38 +07:00
Okan YILDIRIM a68dc55490 fix(app): refresh visible usage summaries periodically (#7806) 2026-11-06 03:03:57 +00:00
Sebastian Arena 65ae51dde1 M2: review fixes: retry attempt guard, cancellable native pending start, measured terminal slot width, compact-web Cmd+D for terminal dictation 2026-10-26 22:29:02 -04:00
Sebastian Arena 113dd83d95 M2: terminal dictation: mic in tab-bar slot and key bar, recording pill and overlay, per-terminal recording lifetime incl. pending mic acquisition, Cmd+D on macOS by keyboard focus, i18n 2026-10-26 21:59:03 -04:00
Sebastian Arena 1eea1381eb M1: terminal input-sink bridge, upload button in tab-bar slot and key bar, picked-file routing, space-separated pasted paths 2026-10-26 21:09:19 -04:00
295 changed files with 15597 additions and 2649 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 0.15.3 - 2026-11-06
### Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+ ([#8918](https://github.com/getpaseo/paseo/pull/8918) by [@dgreensp](https://github.com/dgreensp))
## 0.15.2 - 2026-11-05
### Added
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 0.15.3 - 2026-11-06
### 修复
- 修复了 iOS 26 及更高版本中打开或收起键盘时聊天内容变模糊的问题 ([#8918](https://github.com/getpaseo/paseo/pull/8918),贡献者:[@dgreensp](https://github.com/dgreensp))
## 0.15.2 - 2026-11-05
### 新增
+33 -3
View File
@@ -136,6 +136,12 @@ 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.
Agent-scoped workspace creation records the caller's workspace as `parentWorkspaceId`.
This is informational ancestry for future workspace grouping. It does not change placement,
visibility, status, or archive behavior. Archiving a parent workspace preserves its children's
records and parentage. Unscoped creation and existing workspaces have no parent; ancestry is
never inferred from directories, projects, or Git branches.
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.
Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility.
@@ -253,6 +259,27 @@ archived workspace. History navigation must not infer workspace lifecycle from `
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
only the route's explicit Unarchive or Restore action changes the archived workspace.
Workspace archive is undoable from the archived toast, or with Cmd/Ctrl+Z, while the toast is up.
The daemon runs one archive lane per workspace, shared by every connection
(`packages/server/src/server/workspace-archive-operations.ts`):
| A request arrives while | Result |
| ------------------------ | --------------------------------------------------------- |
| an archive runs: archive | joins the running archive |
| an archive runs: undo | aborts the archive, waits for it to settle, then restores |
| an undo runs: archive | waits for the undo, then archives |
| an undo runs: undo | joins the running undo |
A request takes its place in the lane before its first `await`. Arrival order decides, so an undo
sent right behind its archive still cancels it.
An aborted archive stops at its next checkpoint and reports `canceled`; the undo unarchives only
the agents that archive itself archived. Deleting the worktree directory is the point of no
return: an undo after it recreates the worktree through workspace recovery. The client shows or
hides the row by the latest local intent, so a late remove or upsert from a superseded request
does not flip it back. Tabs and layout are purged only once the undo window closes with the
archive committed.
History navigation opens the selected agent without changing either archive state. Workspace
**Restore** recovers only the workspace; the selected archived agent stays open with its callout.
The agent's **Unarchive** runs the provider's native unarchive hook before interactive resume and
@@ -307,10 +334,13 @@ Running provider-native subagents contribute `running` to the workspace owned by
A finished or failed workspace can be marked unread after it has been reviewed. The daemon restores
attention on its newest eligible workspace-root agent, `error` for an agent whose lifecycle is
`error` and `finished` otherwise, without sending a new notification. Opening the workspace clears that attention through the normal focus flow.
`error` and `finished` otherwise, without sending a new notification. Opening a workspace restores
its selected tab; attention on another agent remains unread until that agent is focused or the
workspace is explicitly marked read.
Attention is set by the agent finishing or failing and cleared by the client's
`workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is
Attention is set by the agent finishing or failing and acknowledged by deliberate focus or
interaction with its chat, including repeat clicks in an already focused pane. A completion
arriving while focused stays unread until the next interaction; leaving does not acknowledge it. Loading an agent's runtime is
neither, so resuming carries the stored attention and the stored last-activity time through
untouched. Forging either makes a background resume look like the user read a workspace and like the
agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted
+1
View File
@@ -34,6 +34,7 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
- **A turn boundary commits the queue before turn liveness changes.** The final reply arrives a few milliseconds before `turn_completed`, so it is still queued when the turn ends. `applyStreamEvent` in `viewed-timeline-sync.ts` flushes the agent's queue first; otherwise the chat paints a finished turn with no reply, drops the footer, and jumps twice.
- **A history row re-renders only when its item or layout item identity changes.** Every
coalesced tick hands the list a new layout. Without a memo boundary each tick re-rendered every
mounted row (measured on the inverted FlatList native used before LegendList: about 50 rows on a
+5 -1
View File
@@ -70,6 +70,10 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als
Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`.
Dense toolbar rows, such as the composer's, are the exception to the 44pt control. Touch-sized the row is 44pt tall and each control is a 36pt circle (`TOOLBAR_DISC_SIZE`): send and stop fill it, and every other control's pressed highlight fills it, so every highlight is the same fully rounded shape. Labelled pickers are 36pt pills. A control lays out at the width of what it paints (`toolbarSlot`, `toolbarContentSlot`) and its circle reaches into the gaps, so `TOOLBAR_CONTROL_GAP`, 20pt, is the visible space between glyphs. Toolbar glyphs match the header icons: 20pt, the app's 1.5px stroke, resting at `foreground` (`toolbarGlyphsFor`). The context ring is 18pt with a 2px stroke (`TOOLBAR_RINGS`): a circle fills its box where an outline glyph does not, and a thin circle reads lighter than a glyph at the same stroke. A disc's solid glyph, such as the stop square, stays at the `md` size (`TOOLBAR_DISC_GLYPH_SIZES`). Pointer-sized toolbars keep 28px controls, a 4px gap, and a 16pt muted glyph that brightens on hover.
In the composer, the first glyph's ink starts on the text column's edge, and the trailing control is always a disc, so the row does not shift as it changes between voice mode, send and stop. The disc nests into the corner, as far from the right edge as from the bottom, and touch-sized the composer's corner radius is concentric with it (`packages/app/src/composer/input/toolbar-rails.ts`).
One gate, `resolveControlSizing`, picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. An iPad with the sidebar docked is wide and touch-sized; a desktop is pointer-sized; a phone is both compact and touch. The touch-screen fact is read once at startup (`hasTouchScreen` in `hooks/use-fine-pointer.ts`), so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take sizes from `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so a caller never sees which sizing it got. Rows, icon buttons and icon slots take theirs from `styles/density.ts`, which uses the same gate. An `{ xs, md }` literal for a hit target or glyph is wrong: on an iPad it yields the pointer size.
Buttons are pills (`borderRadius.full`) with medium-weight labels; an icon-only button is a circle. `<Switch>` follows the same two sizes: a 34×20 track by default and in `sm`, 44×26 where controls are touch-sized, and it reserves its size's control height so a row holding one lines up with a row holding a button. Buttons, text fields, dropdown triggers, and `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) share the size contract — the same size name means the same height and label size on every control kind, so controls in one row line up. Segments run tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. A settings row sizes the controls in its trailing slot itself, default in a card and `sm` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Code that computes with a control's height outside a stylesheet uses `controlHeightForLayout`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
@@ -140,7 +144,7 @@ Things align to their glyphs, not to their boxes. A row's leading icon, its titl
Pick the rails from the content, then hold them. A settings card establishes a leading rail at the icon's left edge and a trailing rail at the last glyph's right edge; every row in that card uses the same two. A row whose icon is absent still starts its title on the leading rail. Indentation is a new rail, not an arbitrary offset.
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct.
The pressable is bigger than the glyph, and that is fine. Hit areas grow outward from the aligned content — they never move it. A button that looks two pixels off because its padding is asymmetric is misaligned even though its box is correct. The composer toolbar is the worked example: its outermost ink, the attach `+` on the left and the trailing glyph or send/stop disc on the right, sits on the text column's edges, and the 44pt touch slots overhang into the composer padding by each end control's ink inset (`composerToolbarOverhang`).
Optical alignment beats arithmetic when a glyph disagrees with its bounding box. Icons with visual weight on one side, chevrons, and single-character labels usually need a small nudge to look centered. Trust the eye, then leave a comment saying the offset is optical.
+1 -1
View File
@@ -68,7 +68,7 @@ When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
into a host workspace, use `navigateToWorkspace()`. Do not make the caller
branch on its current route.
Pass only `serverId` and `workspaceId` for normal attention-aware navigation.
Pass only `serverId` and `workspaceId` to restore the workspace’s selected tab.
When the action names a specific tab, pass it as `target`; that explicit choice
is authoritative. Callers should not choose between separate route and tab
navigation APIs.
+7
View File
@@ -84,3 +84,10 @@ its DOM-derived scope.
Escape interrupts an agent only in the message input or an unclaimed surface.
A focused terminal excludes that binding. The source leaves terminal Escape alone;
terminal Escape/Ctrl forwarding is a separate feature.
## Keys shared with text editing
A binding on a key the platform already uses for editing, such as Cmd/Ctrl+Z for
workspace archive undo, declares `editable: false` and registers its handler with
`isActive` true only while it has work. An inactive handler leaves the key
unhandled, so the pipeline skips `preventDefault` and the platform's own undo runs.
+3 -2
View File
@@ -226,7 +226,8 @@ its own.
inactive. An async action can navigate before its menu closes; a hidden panel must not leave a
portal backdrop blocking the destination. On web the chat suspends one commit after it goes
inactive, and that inactive commit is where the surface unmounts.
- Anchoring, flipping, and edge clamping live in `components/ui/anchor.ts`, shared with the hover
card, and are unit-tested. Fix positioning bugs there, not at a call site.
- Anchoring, flipping, edge clamping, and capping a surface's width to the display live in
`components/ui/anchor.ts`, shared with the hover card, and are unit-tested. Clamping moves a
surface but cannot shrink it, so the width cap is what keeps a wide menu on a narrow screen. Fix positioning bugs there, not at a call site.
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md).
+8 -5
View File
@@ -21,11 +21,14 @@ Which triggers keep terminals and which close them is owned by the stop reason (
## Record, socket and secret
The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its identity (birth time + command line carrying `--paseo-terminal-worker <recordId>`), protocol version, socket path, a random secret, the copy it runs from, and each terminal program's pid and birth time.
The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its pid, protocol version, socket path, random secret, runtime copy and terminal programs. One daemon holds the home's pid lock. Its records and sockets belong to that home.
- The first frame on the socket must be `hello` with the secret; anything else closes the connection. Anyone who connects can type into every shell, so the record is `0600`, the unix socket `0600` in a `0700` directory.
- Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Dev homes under worktrees blow it, so `terminal-worker-socket-path.ts` tries `$PASEO_HOME/terminal-worker/sock/`, then `$TMPDIR/paseo-<uid>/`, then `/tmp/paseo-<uid>/`, and accepts an existing directory only if it is a real `0700` directory owned by you. It never chmods a shared root.
- Cleanup signals a process only when its birth time matches the record. Birth times come from `ps -o lstart` with `LC_ALL=C` (it follows `LC_TIME` otherwise) and from `Win32_Process.CreationDate`. A missing birth time is "unknown", never matched by command text: a shell that ran `exec` keeps its pid and birth time but not its argv.
- The first frame must be `hello` with the secret; anything else closes the connection. Anyone authenticated can type into every shell, so the record and Unix socket are `0600` inside a `0700` directory.
- New socket names include the record id. A stale record whose pid belongs to a newer worker cannot reach that newer worker. Existing records keep their recorded paths. Legacy paths use only the home and pid, so force-killing through one also requires successful authentication. A legacy socket that cannot authenticate stays unconfirmed.
- A missing pid or refused socket retires the record without signaling the worker. An accepting socket identifies the live worker. Cleanup asks it to shut down, then probes again before force-killing it after the grace period (`taskkill /T` on Windows). An inconclusive probe counts toward the worker limit and is retried on the next start or stop without signaling it.
- Workers never query their own birth time or command line. Readers accept legacy identity fields but ignore them. Daemons through 0.15.1 report new records unreadable and cannot adopt them.
- Windows terminal programs have no recorded birth time and terminal lifecycle operations never launch PowerShell. Linux and macOS retain program birth-time checks through `ps` with `LC_ALL=C`; Linux also walks sessions to find surviving jobs.
- Unix socket paths have a byte budget (103 on macOS, 107 on Linux). Long dev homes use an owner-only fallback under `$TMPDIR/paseo-<uid>/` or `/tmp/paseo-<uid>/`. An existing directory must be a real `0700` directory owned by you; a shared root is never chmodded.
- A kill cleanup that cannot confirm every process gone writes `closing-<recordId>.json` beside the worker record. The worker rewrites its own record whenever a terminal starts or exits, so the kill intent has to live in a file it never writes. A closing record is never adopted; the next stop or start retries it.
- An unreadable record of either kind makes `daemon status` and a kill restart fail with its path: a kill cleanup that met it could not check what it names, and leaves nothing else behind. A read that fails with `EPERM`, `EBUSY` or `EACCES` is retried for 250ms first: on Windows, opening a record while the worker renames its new copy over it fails that way for a moment.
@@ -40,7 +43,7 @@ The worker writes `$PASEO_HOME/terminal-worker/<pid>.json` (owner-only) with its
| two or more older | shuts it down; its terminals close (logged) |
| newer, or a version it cannot read | leaves it running, undriven; `list_terminals` and `create_terminal` answer `rpc_error` `terminal_worker_incompatible`, and `paseo daemon status` prints why |
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable or unidentified worker counts, and so does one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
Never more than two workers run. A new worker launches only when none is current, none is incompatible and fewer than two run. The count comes from the records, so an unreachable worker counts, as does one whose socket probe is inconclusive, and one a cleanup could not confirm gone. When two run, the daemon starts with no current worker, and the first terminal created after one of them exits launches it.
**The frozen envelope never changes across versions:** the frame format (4-byte length + `v8.serialize`), `hello`/`helloAck`, the `listTerminals` info fields and `shutdown`. That is what lets any daemon close any worker: a kill stop sends `shutdown` to a newer worker it cannot otherwise drive. Protocol 5 hard cuts unreleased protocols 1 through 4, including the acknowledgement-based replay contract: those workers are obsolete and close. For future versions, keep a previous-version adapter so the draining worker can still be driven, and drop the adapter for the version before that.
@@ -0,0 +1,4 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
@@ -0,0 +1,4 @@
Fixed
- Fixed conversation text becoming blurred when opening or dismissing the keyboard on iOS 26+
Full notes: https://paseo.sh/changelog
+1 -1
View File
@@ -1 +1 @@
sha256-wO0vJVhh1urkt4q9NiQ0RSXUj++WWuHTneWSPelU+JQ=
sha256-vwivjBU5VFpM9VD7Wx4IKHN6xEVlg/KBMJCCMuA1JEw=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.15.2",
"version": "0.15.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.15.2",
"version": "0.15.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -37468,7 +37468,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.15.2",
"version": "0.15.3",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -39637,13 +39637,13 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.15.2",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/server": "0.15.2",
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/server": "0.15.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -39890,11 +39890,11 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.15.2",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/protocol": "0.15.2",
"@getpaseo/relay": "0.15.2",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -39906,7 +39906,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.15.2",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40149,7 +40149,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.15.2",
"version": "0.15.3",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40195,7 +40195,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.15.2",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"@codemirror/language": "6.12.4",
@@ -40225,7 +40225,7 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.15.2",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
@@ -40233,8 +40233,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -40244,8 +40244,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/client": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -40267,7 +40267,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.15.2",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"semver": "^7.8.5",
@@ -40295,7 +40295,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.15.2",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"@noble/ciphers": "^2.4.0",
@@ -40517,17 +40517,17 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.15.2",
"version": "0.15.3",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.15.2",
"@getpaseo/highlight": "0.15.2",
"@getpaseo/plugin": "0.15.2",
"@getpaseo/protocol": "0.15.2",
"@getpaseo/relay": "0.15.2",
"@getpaseo/client": "0.15.3",
"@getpaseo/highlight": "0.15.3",
"@getpaseo/plugin": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -41474,7 +41474,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.15.2",
"version": "0.15.3",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -43084,7 +43084,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.15.2",
"version": "0.15.3",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.15.2",
"version": "0.15.3",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -235,6 +235,7 @@ async function beginTimelineRowStabilityCheck(
lastTop: initialRow.getBoundingClientRect().top,
largestDownwardShift: 0,
sawMissing: false,
sawHidden: false,
};
const windowState = window as unknown as Record<string, unknown>;
windowState.__messageSubmissionTimelineRowStability = state;
@@ -245,6 +246,9 @@ async function beginTimelineRowStabilityCheck(
requestAnimationFrame(checkFrame);
return;
}
if (!initialRow.checkVisibility({ opacityProperty: true, visibilityProperty: true })) {
state.sawHidden = true;
}
const top = initialRow.getBoundingClientRect().top;
state.largestDownwardShift = Math.max(state.largestDownwardShift, top - state.lastTop);
state.lastTop = top;
@@ -257,14 +261,24 @@ async function beginTimelineRowStabilityCheck(
const result = await page.evaluate(() => {
const windowState = window as unknown as Record<string, unknown>;
const state = windowState.__messageSubmissionTimelineRowStability as
| { active: boolean; largestDownwardShift: number; sawMissing: boolean }
| {
active: boolean;
largestDownwardShift: number;
sawMissing: boolean;
sawHidden: boolean;
}
| undefined;
if (!state) throw new Error("Timeline-row stability check was not started");
state.active = false;
delete windowState.__messageSubmissionTimelineRowStability;
return { largestDownwardShift: state.largestDownwardShift, sawMissing: state.sawMissing };
return {
largestDownwardShift: state.largestDownwardShift,
sawMissing: state.sawMissing,
sawHidden: state.sawHidden,
};
});
expect(result.sawMissing).toBe(false);
expect(result.sawHidden).toBe(false);
expect(result.largestDownwardShift).toBeLessThanOrEqual(2);
};
}
@@ -1178,7 +1192,13 @@ test.describe("Agent message submission", () => {
test.setTimeout(120_000);
const toasts = await recordPanelToasts(page);
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
// The created agent takes over the draft's timeline: the same row stays mounted and painted.
const finishTimelineRowStabilityCheck = await beginTimelineRowStabilityCheck(
page,
pending.userMessage,
);
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
await finishTimelineRowStabilityCheck();
// A chat this client just created is current by construction; it is never out of date.
await toasts.expectNeverShown("agent-updating-toast");
});
@@ -18,8 +18,10 @@ test.describe("Agent timeline sync retry", () => {
const gate = await installDaemonWebSocketGate(page);
const agent = await seedLongMockAgentTimeline({ turns: 2 });
try {
gate.holdNextClientRequest("clear_agent_attention");
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await gate.waitForHeldClientRequest();
gate.failTimelineResponses(agent.agentId);
await disconnectViewedTimeline(page, gate);
@@ -0,0 +1,200 @@
import type { Page } from "@playwright/test";
import { expect, test as baseTest } from "../support/fixtures";
import { expectAgentIdle } from "../support/helpers/agent-stream";
import { submitMessage } from "../support/helpers/composer";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
// A turn ends with its final reply, `turn_completed`, and the idle status snapshot arriving
// within a few milliseconds of each other. These tests hold all three, let the running
// turn paint, then release them together and sample every painted frame.
interface Box {
top: number;
height: number;
}
interface TurnEndFrame {
at: number;
running: boolean;
footer: Box | null;
userRow: Box | null;
replyVisible: boolean;
}
declare global {
interface Window {
__turnEndFrames?: { active: boolean; frames: TurnEndFrame[] };
}
}
const test = baseTest.extend<{ agent: MockAgentWorkspace }>({
agent: async ({ page: _page }, provide) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "turn-end-",
title: "Turn end",
initialPrompt: "emit 4 tool agent stream updates",
});
await agent.client.waitForFinish(agent.agentId, 15_000);
await provide(agent);
await agent.cleanup();
},
});
async function recordTurnEndFrames(
page: Page,
input: { prompt: string; reply: string | null },
): Promise<void> {
await page.evaluate(({ prompt, reply }) => {
const state = { active: true, frames: [] as TurnEndFrame[] };
const isVisible = (element: Element | null | undefined) => Boolean(element?.checkVisibility());
const boxOf = (element: Element | null | undefined): Box | null => {
if (!element || !isVisible(element)) return null;
const rect = element.getBoundingClientRect();
return rect.height > 0 ? { top: rect.top, height: rect.height } : null;
};
const sample = () => {
const viewport = Array.from(
document.querySelectorAll('[data-testid="agent-chat-scroll"]'),
).find((candidate) => isVisible(candidate));
const footers = Array.from(viewport?.querySelectorAll('[data-testid="turn-footer"]') ?? [])
.map(boxOf)
.filter((box): box is Box => box !== null)
.sort((left, right) => left.top - right.top);
const userRow = Array.from(
viewport?.querySelectorAll('[data-testid="user-message"]') ?? [],
).find((candidate) => candidate.textContent?.includes(prompt));
const replyRow =
reply === null
? undefined
: Array.from(viewport?.querySelectorAll('[data-testid="assistant-message"]') ?? []).find(
(candidate) => candidate.textContent?.includes(reply),
);
state.frames.push({
at: performance.now(),
running: Boolean(viewport?.querySelector('[data-testid="turn-working-indicator"]')),
footer: footers.at(-1) ?? null,
userRow: boxOf(userRow),
replyVisible: boxOf(replyRow) !== null,
});
if (state.active) requestAnimationFrame(() => setTimeout(sample, 0));
};
window.__turnEndFrames = state;
requestAnimationFrame(() => setTimeout(sample, 0));
}, input);
}
async function recordPaintsFor(page: Page, durationMs: number): Promise<void> {
const until = await page.evaluate((duration) => performance.now() + duration, durationMs);
await expect
.poll(() => page.evaluate(() => window.__turnEndFrames?.frames.at(-1)?.at ?? 0))
.toBeGreaterThanOrEqual(until);
}
async function stopRecording(page: Page): Promise<TurnEndFrame[]> {
return page.evaluate(() => {
const state = window.__turnEndFrames;
if (!state) throw new Error("Turn end frames were not recorded");
state.active = false;
return state.frames;
});
}
async function recordTurnEnd(
page: Page,
agent: MockAgentWorkspace,
input: { prompt: string; reply: string | null },
): Promise<TurnEndFrame[]> {
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectAgentIdle(page);
if (input.reply === null) {
gate.setAgentStreamItemSuppressed("assistant_message", true);
} else {
gate.holdNextAgentStreamItem("assistant_message");
}
gate.holdNextAgentStreamEvent("turn_completed");
// The daemon sends more than one idle snapshot; hold the first and drop the rest until release.
gate.holdNextAgentUpdate(agent.agentId, "idle");
gate.setServerMessageSuppressed("agent_update", true);
await recordTurnEndFrames(page, input);
await submitMessage(page, input.prompt);
await Promise.all([
input.reply === null ? Promise.resolve() : gate.waitForHeldAgentStreamItem("assistant_message"),
gate.waitForHeldAgentStreamEvent("turn_completed"),
gate.waitForHeldAgentUpdate(agent.agentId, "idle"),
]);
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await recordPaintsFor(page, 200);
if (input.reply !== null) gate.releaseHeldAgentStreamItem("assistant_message");
gate.releaseHeldAgentStreamEvent("turn_completed");
gate.releaseHeldAgentUpdate(agent.agentId, "idle");
gate.setServerMessageSuppressed("agent_update", false);
await expectAgentIdle(page);
await recordPaintsFor(page, 300);
return stopRecording(page);
}
function describeFrames(frames: TurnEndFrame[]): string {
return frames
.map(
(frame, index) =>
`${index}: ${frame.running ? "running " : "complete"} footer=${
frame.footer ? `${frame.footer.top.toFixed(1)}+${frame.footer.height.toFixed(1)}` : "none"
} prompt=${frame.userRow?.top.toFixed(1) ?? "none"} reply=${frame.replyVisible}`,
)
.join("\n");
}
function expectSteadyTurnEnd(frames: TurnEndFrame[], input: { reply: string | null }): void {
const firstRunning = frames.findIndex((frame) => frame.running && frame.footer !== null);
const firstComplete = frames.findIndex((frame, index) => index > firstRunning && !frame.running);
const record = `frames:\n${describeFrames(frames)}`;
expect(firstRunning, record).toBeGreaterThanOrEqual(0);
expect(firstComplete, record).toBeGreaterThan(firstRunning);
const transition = frames.slice(firstRunning);
const runningHeight = transition[0].footer?.height;
expect(
transition.map((frame) => frame.footer?.height ?? null),
`the footer must stay painted at one height\n${record}`,
).toEqual(transition.map(() => runningHeight));
if (input.reply !== null) {
expect(
frames.slice(firstComplete).every((frame) => frame.replyVisible),
`a completed turn must show its reply in the same frame\n${record}`,
).toBe(true);
}
// Tool rows land while the turn runs; only the turn end itself must be one step.
const ending = frames.slice(firstComplete - 1);
const promptMoves = ending
.slice(1)
.filter((frame, index) => frame.userRow?.top !== ending[index].userRow?.top);
expect(
promptMoves.length,
`the prompt must move at most once as the turn ends\n${record}`,
).toBeLessThanOrEqual(1);
}
test.describe("Turn end", () => {
test("ends a plain reply without moving or dropping its footer", async ({ page, agent }) => {
const input = { prompt: "emit 1 coalesced agent stream updates", reply: "stress-update-0" };
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
});
test("ends a tool turn with its reply and completed footer together", async ({ page, agent }) => {
const input = { prompt: "emit 3 tool agent stream updates", reply: "Completed 3 commands" };
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
});
test("keeps the footer height when a turn ends on a tool call", async ({ page, agent }) => {
const input = { prompt: "emit 2 tool agent stream updates", reply: null };
expectSteadyTurnEnd(await recordTurnEnd(page, agent, input), input);
});
});
@@ -1,4 +1,7 @@
import { writeFile, unlink } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { gotoAppShell } from "../support/helpers/app";
import {
expectNoBranchSwitcherInWorkspaceHeader,
@@ -14,6 +17,88 @@ import {
waitForSidebarHydration,
} from "../support/helpers/workspace-ui";
// Hold the real daemon response until the test has observed the pending UI.
async function holdBranchSuggestions(page: Page) {
let requestCount = 0;
let release!: () => void;
let gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => {
const envelope = JSON.parse(message.toString());
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_request")
requestCount++;
server.send(message);
});
server.onMessage(async (message) => {
const envelope = JSON.parse(message.toString());
if (envelope.type === "session" && envelope.message?.type === "branch_suggestions_response") {
await gate;
}
browser.send(message);
});
});
return {
release: () => release(),
holdNextResponse: () => {
gate = new Promise<void>((resolve) => {
release = resolve;
});
},
requestCount: () => requestCount,
};
}
async function expectInitialBranchLoading(page: Page, release: () => void): Promise<void> {
await page.getByRole("button", { name: "Current branch: main. Press to switch branch." }).click();
const picker = page.getByTestId("combobox-desktop-container");
await expect(picker.getByText("Loading...", { exact: true })).toBeVisible();
await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0);
release();
await expect(picker.getByText("dev", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(picker).not.toBeVisible();
}
async function expectFailedRefreshRecovery(
page: Page,
repoPath: string,
responses: Awaited<ReturnType<typeof holdBranchSuggestions>>,
) {
// Expire the actual query freshness window before reopening the cached picker.
const started = Date.now();
await expect.poll(() => Date.now() - started, { timeout: 20_000 }).toBeGreaterThan(15_000);
const picker = page.getByTestId("combobox-desktop-container");
const packedRefs = path.join(repoPath, ".git", "packed-refs");
await writeFile(packedRefs, "invalid packed refs\n");
try {
await page
.getByRole("button", { name: "Current branch: dev. Press to switch branch." })
.click();
await expect(picker.getByRole("alert")).toHaveText(
"Could not load branches. Reopen the picker to try again.",
);
} finally {
await unlink(packedRefs);
}
await expect(picker.getByText("No branches found.", { exact: true })).toHaveCount(0);
responses.holdNextResponse();
const beforeRetry = responses.requestCount();
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Current branch: dev. Press to switch branch." }).click();
await expect(picker.getByRole("alert")).toHaveText("Loading...");
await expect.poll(responses.requestCount).toBe(beforeRetry + 1);
await expect(picker).toBeVisible();
responses.release();
await expect(picker.getByRole("alert")).toHaveCount(0);
await expect(picker.getByText("main", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expectWorkspaceBranch(page, "dev");
expect(responses.requestCount()).toBe(beforeRetry + 1);
}
async function renameWorkspaceViaSidebar(
page: Page,
input: { workspaceId: string; title: string },
@@ -59,6 +144,7 @@ test.describe("Branch switcher", () => {
try {
expect(workspace.workspaceName).toBe("main");
const releaseBranches = await holdBranchSuggestions(page);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: workspace.workspaceId });
@@ -82,8 +168,10 @@ test.describe("Branch switcher", () => {
// and switching it checks out the real branch on disk.
await openChangesPanel(page);
await expectWorkspaceBranch(page, "main");
await expectInitialBranchLoading(page, releaseBranches.release);
await switchBranchFromChangesPanel(page, { from: "main", to: "dev" });
await expectWorkspaceBranch(page, "dev");
await expectFailedRefreshRecovery(page, workspace.repoPath, releaseBranches);
// The custom title is unaffected by the branch switch.
await expect(headerTitle).toHaveText(customTitle, { timeout: 30_000 });
@@ -427,6 +427,15 @@ test("Changes shows the base as text on a host without the base-branch capabilit
expect(baseRequests).toBe(0);
});
test("Changes shows no base on a checkout that is not a Paseo worktree", async ({ page }) => {
const workspace = await createWorkspaceWithCommittedDiff();
await openWorkspaceChangesSurface(page, workspace);
const header = page.getByTestId("changes-repository-header").filter({ visible: true });
await expect(header).toContainText("feature");
await expect(header).not.toContainText("main");
await expect(page.getByTestId("workspace-base-branch-trigger")).toHaveCount(0);
});
test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
@@ -5,8 +5,10 @@ import {
holdDirectorySuggestionResponses,
expectOneLineTruncatedFileResult,
expectStableCommandCenterLayout,
expectLayoutObservationToCatchPanelShift,
failDirectorySuggestionRequests,
startCommandCenterLayoutObservation,
shiftOutsideCommandCenter,
} from "../support/helpers/command-center-file-search";
import {
scrollCommandCenterAwayFromSelection,
@@ -84,7 +86,9 @@ test("workspace file search stays geometrically stable through delayed loading a
await expect(panel.getByTestId("command-center-file-search-loading")).toBeVisible();
await expect(panel.getByText("Searching files...", { exact: true })).toHaveCount(0);
await expect(panel.getByTestId("command-center-file-search-loading")).toBeHidden();
await shiftOutsideCommandCenter(page);
await expectStableCommandCenterLayout(page);
await expectLayoutObservationToCatchPanelShift(page);
await row.click();
await expectFileTabOpen(page, FILE_PATH);
@@ -105,7 +105,21 @@ interface PopoverFrameRecorderWindow extends Window {
__stopComposerAutocompleteFrameRecorder?: () => void;
}
async function installListCommandsStub(page: Page): Promise<void> {
interface ListCommandsReply {
commands: readonly { name: string; description: string; argumentHint: string }[];
error: string | null;
}
const TEST_COMMANDS_REPLY: ListCommandsReply = { commands: TEST_COMMANDS, error: null };
/**
* Rewrites every `list_commands_response` with `reply()`. A reply that returns a promise holds
* the response until it settles, standing in for a slow host.
*/
async function installListCommandsStub(
page: Page,
reply: () => ListCommandsReply | Promise<ListCommandsReply> = () => TEST_COMMANDS_REPLY,
): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
@@ -130,14 +144,20 @@ async function installListCommandsStub(page: Page): Promise<void> {
};
};
};
const payload = parsed.message?.payload;
if (
parsed.type === "session" &&
parsed.message?.type === "list_commands_response" &&
parsed.message.payload
payload
) {
parsed.message.payload.commands = TEST_COMMANDS;
parsed.message.payload.error = null;
ws.send(JSON.stringify(parsed));
const send = (result: ListCommandsReply) => {
payload.commands = result.commands;
payload.error = result.error;
ws.send(JSON.stringify(parsed));
};
const result = reply();
if (result instanceof Promise) void result.then(send);
else send(result);
return;
}
} catch {
@@ -149,6 +169,17 @@ async function installListCommandsStub(page: Page): Promise<void> {
});
}
function createHeldReply(): {
reply: () => Promise<ListCommandsReply>;
release: () => void;
} {
let release = () => {};
const held = new Promise<ListCommandsReply>((resolve) => {
release = () => resolve(TEST_COMMANDS_REPLY);
});
return { reply: () => held, release };
}
async function openAppWideNewWorkspace(page: Page): Promise<void> {
await page.getByTestId("sidebar-global-new-workspace").first().click();
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
@@ -545,16 +576,17 @@ test.describe("Composer autocomplete", () => {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/he");
// `/hea` lists two commands and `/he` enough to reach the height cap, so the list grows.
await input.fill("/hea");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
await expect(popover.getByText("/heapdump", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const beforeDelete = await visiblePopoverBox(page);
await input.press("Backspace");
await expect(input).toHaveValue("/h");
await expect(popover.getByText("/history", { exact: true }).first()).toBeVisible({
await expect(input).toHaveValue("/he");
await expect(popover.getByText("/hello", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
const afterDelete = await visiblePopoverBox(page);
@@ -733,6 +765,116 @@ test.describe("Composer autocomplete", () => {
}
});
test("forgets an arrow-key choice once the search changes", async ({ page }) => {
await installListCommandsStub(page);
const session = await seedMockAgentWorkspace({
repoPrefix: "autocomplete-stale-choice-",
title: "Autocomplete stale choice",
});
try {
await openAgentRoute(page, session);
await expectWorkspaceTabVisible(page, session.agentId);
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
// Down wraps from the default bottom row to the top: /clear, then /exit.
await input.press("ArrowDown");
await input.press("ArrowDown");
await input.press("h");
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
await input.press("Backspace");
await expect(input).toHaveValue("/");
await input.press("Enter");
await expect(input).toHaveValue("/tdd ");
await expectWorkspaceTabVisible(page, session.agentId);
} finally {
await session.cleanup();
}
});
test("shows loading inside the popover until a slow host answers", async ({ page }) => {
const host = createHeldReply();
await installListCommandsStub(page, host.reply);
const agent = await openReadyMockAgent(page);
try {
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
await expect(popover.getByTestId("composer-autocomplete-loading")).toBeVisible({
timeout: 30_000,
});
const loading = await visiblePopoverBox(page);
host.release();
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await expect(popover.getByTestId("composer-autocomplete-loading")).toHaveCount(0);
const results = await visiblePopoverBox(page);
expect(Math.abs(results.bottom - loading.bottom)).toBeLessThanOrEqual(4);
} finally {
await agent.cleanup();
}
});
test("shows why commands failed, keeps Enter from choosing a hidden command, and retries", async ({
page,
}) => {
let reply: () => ListCommandsReply | Promise<ListCommandsReply> = () => ({
commands: [],
error: "Timed out waiting for the agent session",
});
await installListCommandsStub(page, () => reply());
const session = await seedMockAgentWorkspace({
repoPrefix: "autocomplete-failing-host-",
title: "Autocomplete failing host",
});
try {
await openAgentRoute(page, session);
await expectWorkspaceTabVisible(page, session.agentId);
const input = composerLocator(page);
await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill("/");
const popover = page.getByTestId("composer-autocomplete-popover");
const errorRow = popover.getByTestId("composer-autocomplete-error");
await expect(errorRow).toContainText("Timed out waiting for the agent session", {
timeout: 30_000,
});
await input.press("Enter");
await page.waitForTimeout(500);
await expectWorkspaceTabVisible(page, session.agentId);
await input.fill("/");
await expect(errorRow).toBeVisible({ timeout: 30_000 });
const host = createHeldReply();
reply = host.reply;
await errorRow.getByRole("button", { name: "Retry" }).click();
await expect(popover.getByTestId("composer-autocomplete-loading")).toBeVisible();
host.release();
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
} finally {
await session.cleanup();
}
});
test.describe("compact sidebar layering", () => {
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
@@ -1,8 +1,14 @@
import { test, expect } from "../support/fixtures";
import { openWorkspaceWithAgents } from "../support/helpers/archive-tab";
import { fillComposerDraft } from "../support/helpers/composer";
import {
expectComposerToolbarOnTextRails,
expectNoCollapsedComposerToolbarFrame,
expectNoFrameTruncatesModelBesideThinking,
readSettledModelTriggerLabels,
recordComposerToolbarFrames,
recordModelTriggerFramesFromLoad,
sweepModelTriggerLabels,
} from "../support/helpers/composer-control-density";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -124,3 +130,128 @@ test.describe("Composer controls folding on a touch device", () => {
}
});
});
test.describe("Folded model control on a phone", () => {
// Wide enough for "Ten second stream" and "Low" side by side; narrower, they no longer fit.
test.use({ viewport: { width: 480, height: 844 }, isMobile: true, hasTouch: true });
test.describe.configure({ timeout: 180_000 });
test("hides the thinking label before the model label truncates", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-density-labels-",
title: "Label priority chat",
model: "ten-second-stream",
});
try {
await recordModelTriggerFramesFromLoad(page);
await openAgentRoute(page, session);
await expect(
page.getByTestId("combined-model-selector-thinking-label").filter({ visible: true }),
).toHaveText("Low");
await readSettledModelTriggerLabels(page);
// From the first frame on, the control never guesses: it paints both labels only once it
// has measured that they fit.
await expectNoFrameTruncatesModelBesideThinking(page);
const sweep = await sweepModelTriggerLabels(page, {
from: 480,
to: 280,
step: 4,
height: 844,
});
expect(sweep.widest).toMatchObject({
modelText: "Ten second stream",
thinkingShown: true,
modelTruncated: false,
});
expect(sweep.thinkingHidden).toMatchObject({
modelText: "Ten second stream",
modelTruncated: false,
});
expect(sweep.modelTruncated.width).toBeLessThan(sweep.thinkingHidden.width);
// A clean tail: the label ends on a whole word and the ellipsis, never on a space.
expect(sweep.modelTruncated).toMatchObject({
modelText: "Ten second…",
thinkingShown: false,
});
} finally {
await session.cleanup();
}
});
});
test.describe("Composer toolbar rails on touch", () => {
test.use({ isMobile: true, hasTouch: true });
test.describe.configure({ timeout: 180_000 });
for (const viewport of [
{ device: "phone", width: 390, height: 844 },
{ device: "tablet", width: 1024, height: 1366 },
]) {
test(`the toolbar starts on the text rail and ends in a disc nested in the corner on a ${viewport.device}`, async ({
page,
}) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
const idle = await seedMockAgentWorkspace({
repoPrefix: "composer-rails-idle-",
title: "Rails idle chat",
});
let running: Awaited<ReturnType<typeof seedMockAgentWorkspace>> | null = null;
try {
await openAgentRoute(page, idle);
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toBeVisible({
timeout: 30_000,
});
await test.step("empty composer: attach on the left, voice mode on the right", async () => {
await expectComposerToolbarOnTextRails(page);
});
await test.step("draft: the send disc on the right", async () => {
await fillComposerDraft(page, "Refactor the composer toolbar");
await expect(page.getByRole("button", { name: /send/i }).first()).toBeVisible();
await expectComposerToolbarOnTextRails(page);
});
running = await seedMockAgentWorkspace({
repoPrefix: "composer-rails-running-",
title: "Rails running chat",
model: "one-minute-stream",
initialPrompt: "stream for a while",
});
await openAgentRoute(page, running);
await test.step("running agent: the stop disc on the right", async () => {
await expect(page.getByRole("button", { name: /stop agent/i }).first()).toBeVisible({
timeout: 30_000,
});
await expectComposerToolbarOnTextRails(page);
});
} finally {
await idle.cleanup();
await running?.cleanup();
}
});
}
});
test.describe("Composer trailing disc on a new chat", () => {
test.use({ isMobile: true, hasTouch: true, viewport: { width: 1024, height: 1366 } });
test("an empty draft holds a disabled send disc until there is something to send", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "composer-placeholder-send-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await clickNewChat(page);
const send = page
.getByTestId("message-input-root")
.filter({ visible: true })
.getByRole("button", { name: "Send message", exact: true });
await expect(send).toBeDisabled({ timeout: 30_000 });
await fillComposerDraft(page, "Refactor the composer toolbar");
await expect(send).toBeEnabled();
} finally {
await workspace.cleanup();
}
});
});
@@ -1,7 +1,9 @@
import type { Locator } from "@playwright/test";
import type { CDPSession, Locator } from "@playwright/test";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { seedSubagent } from "../support/helpers/subagents";
import { submitMessage } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
import {
@@ -860,3 +862,91 @@ async function expectReviewEditorBody(page: Page, body: string) {
body,
);
}
async function swipeComposerTracks(page: Page, client: CDPSession, direction: "left" | "right") {
const track = page.getByTestId("composer-track-scroll");
const box = await track.boundingBox();
if (!box) throw new Error("Composer tracks are not visible");
const start = direction === "left" ? box.x + box.width - 5 : box.x + 5;
const end = direction === "left" ? box.x + 5 : box.x + box.width - 5;
await swipeTouch(client, start, end, box.y + box.height / 2);
}
async function swipeTouch(client: CDPSession, start: number, end: number, y: number) {
await client.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x: start, y }],
});
for (let step = 1; step <= 8; step++) {
await client.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: start + ((end - start) * step) / 8, y }],
});
}
await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
}
async function trackScrollOffset(track: Locator): Promise<number> {
return track.evaluate((node) => node.scrollLeft);
}
test.describe("touch composer tracks", () => {
test.use({ hasTouch: true, isMobile: true });
test("compact composer scrolls its task, subagent and changes tracks without squeezing them", async ({
page,
}) => {
const workspace = await seedChangedAgent("composer-tracks-overflow-");
const touch = await page.context().newCDPSession(page);
try {
await seedSubagent(
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
{ parentId: workspace.agentId, title: "Track child" },
);
await page.setViewportSize({ width: 1000, height: 844 });
await openAgentRoute(page, {
workspaceId: workspace.workspaceId,
agentId: workspace.agentId,
});
await submitMessage(page, "emit 1 agent stream updates");
const tasks = page.getByTestId("agent-task-list-header");
const subagents = page.getByTestId("subagents-track-header");
const changes = page.getByTestId("composer-diff-stat-pill");
await expect(tasks).toBeVisible();
await expect(subagents).toBeVisible();
await expect(changes).toBeVisible();
const wideWidths = await Promise.all(
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
);
await page.setViewportSize({ width: 200, height: 844 });
const narrowWidths = await Promise.all(
[tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width),
);
for (const [index, width] of wideWidths.entries())
expect(narrowWidths[index]).toBeGreaterThanOrEqual(width - 1);
const track = page.getByTestId("composer-track-scroll");
expect(await track.evaluate((node) => node.scrollWidth)).toBeGreaterThan(
await track.evaluate((node) => node.clientWidth),
);
await swipeComposerTracks(page, touch, "left");
await expect.poll(() => trackScrollOffset(track)).toBeGreaterThan(0);
await swipeComposerTracks(page, touch, "right");
await expect.poll(() => trackScrollOffset(track)).toBeLessThanOrEqual(1);
await swipeComposerTracks(page, touch, "left");
await expect.poll(() => trackScrollOffset(track)).toBeGreaterThan(0);
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport();
await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeInViewport();
await page.getByRole("button", { name: "Open menu" }).click();
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ ratio: 1 });
await page.getByTestId("sidebar-close").click();
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport();
await expect(changes).toBeInViewport();
await changes.click();
await expect(
page.getByTestId("changes-header").filter({ visible: true }).first(),
).toBeVisible();
} finally {
await touch.detach();
await workspace.cleanup();
}
});
});
@@ -1,7 +1,8 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { mkdirSync, mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "../support/fixtures";
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import {
cleanupRewindFlow,
launchAgent,
@@ -14,7 +15,11 @@ import {
expectTaskListEntries,
openCompletedTaskList,
} from "../support/helpers/task-list";
import { submitMessage } from "../support/helpers/composer";
import { submitMessage, submitMessageWithButton } from "../support/helpers/composer";
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { gotoAppShell } from "../support/helpers/app";
interface TaskScenario {
provider: RewindFlowProvider;
@@ -114,3 +119,425 @@ test.describe("real provider composer task lists", () => {
}
});
});
async function openBackgroundInventory(
page: Page,
options: { compact: boolean; count: number; timeout: number },
): Promise<Locator> {
const pill = page.getByRole("button", {
name: options.count === 1 ? "1 background task" : `${options.count} background tasks`,
});
await expect(pill).toBeVisible({ timeout: options.timeout });
await pill.click();
const panel = page.getByTestId(
options.compact ? "background-work-panel-content" : "background-work-panel",
);
if (options.compact) await waitForSettledPosition(panel);
return panel;
}
async function openBackgroundWork(page: Page, compact: boolean): Promise<void> {
const panel = await openBackgroundInventory(page, { compact, count: 1, timeout: 30_000 });
await expect(panel).toContainText(/sleep 120/);
}
async function expectBackgroundItems(
panel: Locator,
selected: string,
sibling: string,
): Promise<void> {
await expect(panel).toContainText(selected);
await expect(panel).toContainText(sibling);
}
async function stopBackgroundItem(panel: Locator, selected: string): Promise<void> {
const escaped = selected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
await panel.getByRole("button", { name: new RegExp(`Stop .*${escaped}`) }).click();
}
async function expectStoppedItemAndSibling(
panel: Locator,
selected: string,
sibling: string,
): Promise<void> {
await expect(panel).not.toContainText(selected, { timeout: 40_000 });
await expect(panel).toContainText(sibling);
}
async function closeBackgroundWork(page: Page, compact: boolean): Promise<void> {
const panel = page.getByTestId(
compact ? "background-work-panel-content" : "background-work-panel",
);
if (compact) {
await page
.getByRole("button", { name: "Bottom sheet backdrop" })
.click({ position: { x: 12, y: 12 } });
} else {
await page.keyboard.press("Escape");
}
await expect(panel).toBeHidden();
}
async function expectBackgroundAgentRunning(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-status-indicator-running").first()).toBeVisible();
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toHaveCount(0);
}
async function expectAssistantReply(page: Page, text: string): Promise<void> {
await expect(page.getByTestId("assistant-message").filter({ hasText: text }).last()).toBeVisible({
timeout: 120_000,
});
}
async function expectBackgroundPill(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "1 background task" })).toBeVisible();
}
async function expectBackgroundWorkOnAnotherClient(page: Page): Promise<void> {
const another = await page.context().newPage();
try {
await another.goto(page.url());
await openBackgroundWork(another, false);
} finally {
await another.close();
}
}
async function startBackgroundWorkWithoutOpeningPane(workspace: SeededWorkspace): Promise<void> {
const agent = await workspace.client.createAgent({
provider: "claude",
model: "haiku",
modeId: "bypassPermissions",
cwd: workspace.workspaceDirectory,
workspaceId: workspace.workspaceId,
title: "Background work before opening",
});
await workspace.client.sendAgentMessage(
agent.id,
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
);
const result = await workspace.client.waitForFinish(agent.id, 120_000);
expect(result.status).toBe("idle");
}
async function expectWorkspaceRunning(page: Page, workspaceId: string): Promise<void> {
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
await expect(row.getByTestId("workspace-status-indicator-running")).toBeVisible({
timeout: 30_000,
});
}
test("Claude keeps background work visible while accepting another turn", async ({ page }) => {
test.setTimeout(300_000);
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-work-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
await sendMessage(
handle,
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
);
await expectBackgroundAgentRunning(page);
await openBackgroundWork(page, false);
await page.screenshot({
path: path.resolve("../../.artifacts/phase1-claude-background.png"),
animations: "disabled",
});
await closeBackgroundWork(page, false);
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
await expectBackgroundPill(page);
await expectBackgroundWorkOnAnotherClient(page);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("Claude background work remains readable in dark mode", async ({ page }) => {
test.setTimeout(300_000);
await page.addInitScript(() => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "pureBlack" }));
});
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-dark-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
await sendMessage(
handle,
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
);
await openBackgroundWork(page, false);
await page.screenshot({
path: path.resolve("../../.artifacts/review-background-dark.png"),
animations: "disabled",
});
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("Claude background work opens in a compact sheet", async ({ page }) => {
test.setTimeout(300_000);
await page.setViewportSize({ width: 390, height: 844 });
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-compact-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
await submitMessageWithButton(
page,
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
);
await expectAssistantReply(page, "SPAWNED");
await openBackgroundWork(page, true);
await page.screenshot({
path: path.resolve("../../.artifacts/phase1-claude-compact.png"),
animations: "disabled",
});
await closeBackgroundWork(page, true);
await submitMessageWithButton(
page,
"Reply exactly HELLO without stopping the background command.",
);
await expectAssistantReply(page, "HELLO");
await expectBackgroundPill(page);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("background work updates the sidebar before the agent is opened", async ({ page }) => {
test.setTimeout(300_000);
const workspace = await seedWorkspace({ repoPrefix: "background-unopened-" });
try {
await gotoAppShell(page);
await expect(
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`),
).toBeVisible();
await startBackgroundWorkWithoutOpeningPane(workspace);
await expectWorkspaceRunning(page, workspace.workspaceId);
} finally {
await workspace.cleanup();
}
});
test("Codex keeps a live command visible after its turn", async ({ page }) => {
test.setTimeout(300_000);
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-background-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "codex",
cwd,
mode: "full-access",
providerConfig: { model: "gpt-6-astra" },
});
await sendMessage(
handle,
"Use exec_command to run sleep 120 with yield_time_ms 1000. Do not wait for or stop it. Reply SPAWNED immediately.",
);
await expectBackgroundAgentRunning(page);
await openBackgroundWork(page, false);
await page.screenshot({
path: path.resolve("../../.artifacts/phase2-codex-background.png"),
animations: "disabled",
});
await closeBackgroundWork(page, false);
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
await expectBackgroundPill(page);
await expectBackgroundWorkOnAnotherClient(page);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("OpenCode keeps a live command visible after its turn", async ({ page }) => {
test.setTimeout(300_000);
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-opencode-background-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "opencode",
cwd,
mode: "full-access",
providerConfig: { model: "openrouter/google/gemini-3.6-flash" },
});
await sendMessage(
handle,
"Use the shell tool with background true to run sleep 120. Do not wait or stop it. Reply SPAWNED immediately.",
);
await expectBackgroundAgentRunning(page);
await openBackgroundWork(page, false);
await page.screenshot({
path: path.resolve("../../.artifacts/phase2-opencode-background.png"),
animations: "disabled",
});
await closeBackgroundWork(page, false);
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
await expectBackgroundPill(page);
await expectBackgroundWorkOnAnotherClient(page);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
for (const scenario of [
{
provider: "claude" as const,
prompt:
"Use Bash with run_in_background to start sleep 180 and sleep 181 as two separate background commands. Do not wait for them. Reply SPAWNED immediately.",
providerConfig: { model: "haiku" },
},
{
provider: "codex" as const,
prompt:
"Use exec_command twice to start sleep 180 and sleep 181 separately with yield_time_ms 1000. Do not wait for or stop either process. Reply SPAWNED immediately.",
providerConfig: { model: "gpt-6-astra" },
},
{
provider: "opencode" as const,
prompt:
"Use the shell tool twice with background true: start sleep 180, then start sleep 181. Do not wait for or stop either. Reply SPAWNED immediately.",
providerConfig: { model: "openrouter/google/gemini-3.6-flash" },
},
]) {
test(`${scenario.provider} stops selected background work after the turn`, async ({ page }) => {
test.setTimeout(300_000);
mkdirSync(path.resolve("../../.artifacts"), { recursive: true });
const cwd = realpathSync(mkdtempSync(path.resolve("../../.artifacts/phase3-qa-workspace-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: scenario.provider,
cwd,
mode: "full-access",
providerConfig: scenario.providerConfig,
});
await sendMessage(handle, scenario.prompt);
const panel = await openBackgroundInventory(page, {
compact: false,
count: 2,
timeout: 60_000,
});
await expectBackgroundItems(panel, "sleep 180", "sleep 181");
await stopBackgroundItem(panel, "sleep 180");
await expectStoppedItemAndSibling(panel, "sleep 180", "sleep 181");
await page.screenshot({
path: path.resolve(`../../.artifacts/phase3-${scenario.provider}-stop.png`),
animations: "disabled",
});
await page.keyboard.press("Escape");
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
await expectBackgroundPill(page);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
}
test("Claude stops a selected monitor in the compact sheet", async ({ page }) => {
test.setTimeout(300_000);
await page.setViewportSize({ width: 390, height: 844 });
mkdirSync(path.resolve("../../.artifacts"), { recursive: true });
const cwd = realpathSync(mkdtempSync(path.resolve("../../.artifacts/phase3-monitor-workspace-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "claude",
cwd,
mode: "full-access",
providerConfig: { model: "opus" },
});
await submitMessageWithButton(
page,
'Use exactly two separate Monitor tool calls, each with command `sleep 150`, timeout_ms 150000, persistent false, descriptions "probe monitor one" and "probe monitor two". Launch both independently, do not wait, then reply SPAWNED. Do not use any other tools.',
);
await expectAssistantReply(page, "SPAWNED");
const panel = await openBackgroundInventory(page, { compact: true, count: 2, timeout: 30_000 });
await expectBackgroundItems(panel, "probe monitor one", "probe monitor two");
await expect(panel.getByText("sleep 150", { exact: true })).toHaveCount(2);
await page.screenshot({
path: path.resolve("../../.artifacts/refinement-monitor-compact.png"),
animations: "disabled",
});
await stopBackgroundItem(panel, "probe monitor one");
await expectStoppedItemAndSibling(panel, "probe monitor one", "probe monitor two");
await page.screenshot({
path: path.resolve("../../.artifacts/phase3-claude-monitor-compact.png"),
animations: "disabled",
});
await closeBackgroundWork(page, true);
await submitMessageWithButton(
page,
"Reply exactly HELLO without stopping the background monitor.",
);
await expectAssistantReply(page, "HELLO");
await expectBackgroundPill(page);
await handle.client.sendAgentMessage(
handle.agentId,
"Use Bash to run sleep 20 in the foreground, then reply WAITED.",
);
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible({
timeout: 30_000,
});
await handle.client.sendAgentMessage(
handle.agentId,
"Reply exactly INTERRUPTED without stopping the background monitor.",
{ activeTurnBehavior: "interrupt" },
);
expect((await handle.client.waitForFinish(handle.agentId, 120_000)).status).toBe("idle");
await expectBackgroundPill(page);
await expect
.poll(async () => handle!.client.listAgentBackgroundWork(handle!.agentId))
.toEqual(
expect.arrayContaining([
expect.objectContaining({ description: "probe monitor two", kind: "monitor" }),
]),
);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("Claude shows monitor description and command in the wide track", async ({ page }) => {
test.setTimeout(300_000);
const cwd = realpathSync(
mkdtempSync(path.resolve("../../.artifacts/refinement-wide-workspace-")),
);
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "claude",
cwd,
mode: "full-access",
providerConfig: { model: "opus" },
});
await sendMessage(
handle,
'Use Monitor with command `sleep 150`, timeout_ms 150000, persistent false, description "Watch the build". Do not wait; reply SPAWNED. Do not use other tools.',
);
await expectBackgroundAgentRunning(page);
const workspaceRow = page.getByTestId(
`sidebar-workspace-row-${getServerId()}:${handle.workspaceId}`,
);
await workspaceRow.scrollIntoViewIfNeeded();
await expect(workspaceRow.getByTestId("workspace-status-indicator-running")).toBeVisible();
const panel = await openBackgroundInventory(page, {
compact: false,
count: 1,
timeout: 30_000,
});
await expect(panel).toContainText("Watch the build");
await expect(panel).toContainText("sleep 150");
await expect(panel.getByRole("button", { name: "Stop Watch the build" })).toBeVisible();
await page.screenshot({
path: path.resolve("../../.artifacts/refinement-monitor-wide.png"),
animations: "disabled",
});
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
@@ -0,0 +1,127 @@
import { expect, test } from "../support/fixtures";
import { addProjectFlow } from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app";
import {
chooseFooterOverflowAction,
expectFooterLine,
expectFooterMenusInsideViewport,
expectFooterNeverClipped,
expectFooterOverflowActions,
footerOverflowMenu,
openFooterOverflow,
seedDockedSidebarWidth,
watchFooterClipping,
} from "../support/helpers/sidebar-footer-overflow";
import { addOfflineHostsAndReload } from "../support/helpers/hosts";
import { openCompactSidebar } from "../support/helpers/usage-sidebar-item";
const TOUCH_CONTROL = 44;
const MIN_SIDEBAR_WIDTH = 200;
const ALL_INLINE = [
"sidebar-add-project",
"sidebar-usage-icon",
"sidebar-hosts-trigger",
"sidebar-help",
"sidebar-settings",
];
// Five 44pt buttons and the row's padding need 236pt; 200pt holds three and the caret.
const NARROW_TOUCH_LINE = [
"sidebar-add-project",
"sidebar-hosts-trigger",
"sidebar-footer-overflow",
"sidebar-settings",
];
test.describe("Sidebar footer on a touch screen", () => {
test.use({ viewport: { width: 1280, height: 800 }, hasTouch: true });
test("a narrow docked sidebar moves the actions it can't fit behind a caret", async ({
page,
}) => {
await seedDockedSidebarWidth(page, MIN_SIDEBAR_WIDTH);
await watchFooterClipping(page);
await gotoAppShell(page);
await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL });
await expectFooterNeverClipped(page);
await test.step("Help opens as a page of the overflow menu", async () => {
await openFooterOverflow(page);
await expectFooterOverflowActions(page, ["Usage", "Help and support"]);
await chooseFooterOverflowAction(page, "Help and support");
await expect(page.getByRole("menuitem", { name: "What's new" })).toBeVisible();
await expect(page.getByTestId("sidebar-help-menu")).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(footerOverflowMenu(page)).toBeHidden();
});
await test.step("Usage opens from the overflow menu", async () => {
await openFooterOverflow(page);
await chooseFooterOverflowAction(page, "Usage");
await expect(footerOverflowMenu(page)).toBeHidden();
await expect(page.getByTestId("usage-modal")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("usage-modal")).toBeHidden();
});
await test.step("Add project and Settings shortcuts still work", async () => {
await page.keyboard.press("Control+o");
await expect(addProjectFlow(page)).toBeVisible();
await page.keyboard.press("Escape");
await expect(addProjectFlow(page)).toBeHidden();
await page.keyboard.press("Control+,");
await expect(page).toHaveURL(/\/settings\/general$/);
});
});
test("the default sidebar width keeps all five actions inline", async ({ page }) => {
await gotoAppShell(page);
await expectFooterLine(page, { testIDs: ALL_INLINE, controlSize: TOUCH_CONTROL });
});
});
test.describe("Sidebar footer in a compact overlay narrower than the footer", () => {
test.use({ viewport: { width: 220, height: 800 }, hasTouch: true });
test("the overflow menu reaches the hidden actions", async ({ page }) => {
await watchFooterClipping(page);
await gotoAppShell(page);
await openCompactSidebar(page);
await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL });
await expectFooterNeverClipped(page);
await openFooterOverflow(page);
await expectFooterOverflowActions(page, ["Usage", "Help and support"]);
await expectFooterMenusInsideViewport(page);
});
});
test.describe("Sidebar footer on a short, narrow phone", () => {
// 164pt for buttons holds Settings, Add project and the caret, so Hosts moves behind it.
test.use({ viewport: { width: 180, height: 360 }, hasTouch: true });
test("the overflow Hosts page scrolls to its last host and Add host", async ({ page }) => {
await gotoAppShell(page);
await addOfflineHostsAndReload(
page,
Array.from({ length: 10 }, (_, index) => ({
serverId: `overflow-host-${index}`,
label: `Overflow host ${index}`,
})),
);
await openCompactSidebar(page);
await expectFooterLine(page, {
testIDs: ["sidebar-add-project", "sidebar-footer-overflow", "sidebar-settings"],
controlSize: TOUCH_CONTROL,
});
await openFooterOverflow(page);
await expectFooterOverflowActions(page, ["Usage", "Hosts", "Help and support"]);
await expectFooterMenusInsideViewport(page);
await chooseFooterOverflowAction(page, "Hosts");
await expectFooterMenusInsideViewport(page);
const addHost = page.getByRole("menuitem", { name: "Add host" });
await addHost.scrollIntoViewIfNeeded();
await expect(page.getByRole("menuitem", { name: "Overflow host 9" })).toBeVisible();
await expect(addHost).toBeInViewport();
await addHost.click();
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
});
});
@@ -1,12 +1,13 @@
import type { Page } from "@playwright/test";
import { test as base, expect } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
openAgentRoute,
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { expectOpenTabCount } from "../support/helpers/sheet";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { expectReconnectingToastGone } from "../support/helpers/workspace-ui";
import { getServerId } from "../support/helpers/server-id";
import { closeMobileAgentSidebar, openMobileAgentSidebar } from "../support/helpers/sidebar";
@@ -101,11 +102,13 @@ async function leaveMarkedWorkspaceAndReopen(page: Page, { subject, other }: Fin
}
async function completeTurnAndLeave(page: Page, { subject, other }: FinishedWorkspaces) {
await test.step("ordinary completion still clears on departure", async () => {
await test.step("ordinary completion stays unread on departure and clears on return", async () => {
await subject.client.sendAgentMessage(subject.agentId, "Finish another turn.");
await subject.client.waitForFinish(subject.agentId, 20_000);
await expectStatus(page, subject.workspaceId, "attention");
await openWorkspace(page, other.workspaceId);
await expectStatus(page, subject.workspaceId, "attention");
await openWorkspace(page, subject.workspaceId);
await expectStatus(page, subject.workspaceId, "done");
});
}
@@ -141,13 +144,15 @@ async function leaveMarkedWorkspaceAndReopenOnCompact(
}
async function completeTurnAndLeaveOnCompact(page: Page, { subject, other }: FinishedWorkspaces) {
await test.step("ordinary completion still clears on departure", async () => {
await test.step("ordinary completion stays unread on departure and clears on return", async () => {
await closeMobileAgentSidebar(page);
await subject.client.sendAgentMessage(subject.agentId, "Finish another turn.");
await subject.client.waitForFinish(subject.agentId, 20_000);
await openMobileAgentSidebar(page);
await expectStatus(page, subject.workspaceId, "attention");
await openWorkspaceOnCompact(page, other.workspaceId);
await expectStatus(page, subject.workspaceId, "attention");
await openWorkspaceOnCompact(page, subject.workspaceId);
await expectStatus(page, subject.workspaceId, "done");
});
}
@@ -194,11 +199,12 @@ async function markUnreadThenResumeChat(page: Page, workspaceId: string) {
});
}
test("manual unread survives departure and clears on reopening without changing normal completions", async ({
test("manual unread survives departure and clears on reopening and ordinary completion survives departure", async ({
page,
workspaces,
}) => {
await gotoAppShell(page);
await openAgentRoute(page, workspaces.subject);
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
await openWorkspace(page, workspaces.other.workspaceId);
await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId);
await leaveMarkedWorkspaceAndRead(page, workspaces);
@@ -207,16 +213,19 @@ test("manual unread survives departure and clears on reopening without changing
await markBackgroundWorkspaceAndRead(page, workspaces.subject.workspaceId);
});
test("clicking a multi-agent workspace reveals and clears its marked agent", async ({
test("clicking a multi-agent workspace preserves its selected agent and leaves the other unread", async ({
page,
workspaces,
}) => {
await openAgentRoute(page, workspaces.subject);
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
await expectSelectedAgent(page, workspaces.subject.agentId);
await openWorkspace(page, workspaces.other.workspaceId);
const newest = await addFinishedAgent(workspaces.subject);
await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId);
await expectSelectedAgent(page, newest.id);
await addFinishedAgent(workspaces.subject);
await markAsUnread(page, workspaces.subject.workspaceId);
await openWorkspace(page, workspaces.subject.workspaceId);
await expectSelectedAgent(page, workspaces.subject.agentId);
await expectStatus(page, workspaces.subject.workspaceId, "attention");
});
test("manual unread survives leaving the current workspace on compact layout", async ({
@@ -230,7 +239,7 @@ test("manual unread survives leaving the current workspace on compact layout", a
await completeTurnAndLeaveOnCompact(page, workspaces);
});
async function reopenMarkedWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) {
async function reopenWorkspaceAfterBackgroundTurn(page: Page, workspace: MockAgentWorkspace) {
const route = page.url();
// End this app session before the turn, so its cached transcript cannot contain the new prompt.
await page.goto("about:blank");
@@ -265,9 +274,72 @@ test("reopening a marked unread workspace restores only its agent tab and curren
await test.step("mark the current workspace unread, then close and reopen the app", async () => {
await openMobileAgentSidebar(page);
await markAsUnread(page, workspaces.subject.workspaceId);
await reopenMarkedWorkspaceAfterBackgroundTurn(page, workspaces.subject);
await reopenWorkspaceAfterBackgroundTurn(page, workspaces.subject);
});
await test.step("restore the main tab without Explorer entries and load its latest history", async () => {
await expectRestoredCompactChat(page);
});
});
test("clicking the already focused timeline acknowledges a new completion", async ({
page,
workspaces,
}) => {
await openAgentRoute(page, workspaces.subject);
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
await workspaces.subject.client.sendAgentMessage(
workspaces.subject.agentId,
"Finish another turn.",
);
await workspaces.subject.client.waitForFinish(workspaces.subject.agentId, 20_000);
await expectStatus(page, workspaces.subject.workspaceId, "attention");
await page.getByText("Finish another turn.", { exact: true }).click();
await expectStatus(page, workspaces.subject.workspaceId, "done");
});
async function reconnectWithoutInteracting(
page: Page,
gate: Awaited<ReturnType<typeof installDaemonWebSocketGate>>,
agentId: string,
) {
await gate.drop();
await gate.waitForBlockedConnection();
gate.holdTimelineResponses(agentId);
gate.restore();
await gate.waitForHeldTimelineResponse();
await expectReconnectingToastGone(page);
gate.releaseHeldTimelineResponses();
await expect(page.getByRole("alert").filter({ hasText: "Updating messages" })).toHaveCount(0);
}
test("reconnecting a focused chat preserves its unread completion until another click", async ({
page,
workspaces,
}) => {
const gate = await installDaemonWebSocketGate(page);
await openAgentRoute(page, workspaces.subject);
await expect(page.getByText("Finish this turn.", { exact: true })).toBeVisible();
await workspaces.subject.client.sendAgentMessage(
workspaces.subject.agentId,
"Unread across reconnect.",
);
await workspaces.subject.client.waitForFinish(workspaces.subject.agentId, 20_000);
await expectStatus(page, workspaces.subject.workspaceId, "attention");
await reconnectWithoutInteracting(page, gate, workspaces.subject.agentId);
await expectStatus(page, workspaces.subject.workspaceId, "attention");
await page.getByText("Unread across reconnect.", { exact: true }).click();
await expectStatus(page, workspaces.subject.workspaceId, "done");
});
test("a cached read chat acknowledges a completion that arrived while the app was closed", async ({
page,
workspaces,
}) => {
await openCompactWorkspace(page, workspaces.subject);
await expectStatus(page, workspaces.subject.workspaceId, "done");
await closeMobileAgentSidebar(page);
await reopenWorkspaceAfterBackgroundTurn(page, workspaces.subject);
await expectRestoredCompactChat(page);
await openMobileAgentSidebar(page);
await expectStatus(page, workspaces.subject.workspaceId, "done");
});
@@ -45,6 +45,11 @@ process.stdin.on("data", (chunk) => {
for (;;) {
const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer);
if (!paste) break;
const pasted = JSON.stringify(paste[1]).replace(
/\s/g,
(space) => "\\u" + space.charCodeAt(0).toString(16).padStart(4, "0"),
);
process.stdout.write("PASTED:" + pasted + "\r\n");
for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) {
const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex");
const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local";
@@ -202,6 +207,21 @@ test.describe("Terminal protocol queries", () => {
await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text);
}
// Every bracketed paste body the receiver consumed, in order and joined: what a
// shell would read as one command line.
async function pastedText(terminalId: string): Promise<string> {
const output = await terminalOutput(terminalId);
return [...output.matchAll(/PASTED:("(?:[^"\\]|\\.)*")/g)]
.map(([, json]) => JSON.parse(json) as string)
.join("");
}
// Exactly one ASCII space after each quoted path and nothing else between them,
// so the paths stay separate shell words.
async function expectSeparatePastedPaths(terminalId: string): Promise<void> {
await expect.poll(() => pastedText(terminalId)).toMatch(/^'[^']+' '[^']+' $/);
}
test("pastes images from two panes into their own terminals as uploaded host files", async ({
page,
}) => {
@@ -238,6 +258,23 @@ test.describe("Terminal protocol queries", () => {
await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload"));
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload"));
await expectSeparatePastedPaths(terminal.id);
} finally {
await harness.killTerminal(terminal.id);
}
});
test("keeps the paths of two consecutive drops to another machine apart", async ({ page }) => {
const terminal = await openFileReceiver(page, "file-drop-remote-twice");
const first = { name: "first.png", type: "image/png", bytes: pngBytes(10) };
const second = { name: "second.txt", type: "text/plain", bytes: Buffer.from("second drop") };
try {
await dropFiles(page, [first]);
await expectTerminalOutput(terminal.id, fileRead(first.bytes, "upload"));
await dropFiles(page, [second]);
await expectTerminalOutput(terminal.id, fileRead(second.bytes, "upload"));
await expectSeparatePastedPaths(terminal.id);
} finally {
await harness.killTerminal(terminal.id);
}
+69 -7
View File
@@ -9,6 +9,7 @@ import { getActiveTabTestId } from "../support/helpers/launcher";
import type { Page, Locator } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { withDirectoryPlugin } from "../support/helpers/plugin-timeline";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
test.use({
@@ -50,15 +51,16 @@ async function seedAnsweredAgent(title: string) {
}
/** Chat rows between the latest turn's summary line and its reply, in document order. */
async function rowsBetweenLatestSummaryAndReply(page: Page): Promise<number> {
return page.evaluate(() => {
async function rowsBetweenLatestSummaryAndReply(
page: Page,
replyTestId = "assistant-message",
): Promise<number> {
return page.evaluate((testId) => {
const rows = [...document.querySelectorAll("[data-message-id]")];
const summaryRow = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]"));
const replyRow = rows.findLastIndex((row) =>
row.querySelector("[data-testid=assistant-message]"),
);
const replyRow = rows.findLastIndex((row) => row.querySelector(`[data-testid="${testId}"]`));
return replyRow - summaryRow - 1;
});
}, replyTestId);
}
async function groupedToolsInsideLatestTurn(page: Page): Promise<string[]> {
@@ -126,6 +128,67 @@ async function expandAndCollapseLongPrompt(page: Page) {
}
test.describe("Turn collapse", () => {
test("folds work before a plugin-rendered reply and reopens it", async ({ page }, info) => {
test.setTimeout(120_000);
await useChatDisplay(page, "collapseTurns");
const agent = await seedMockAgentWorkspace({
repoPrefix: "plugin-reply-fold-",
title: "Plugin reply folding",
model: "ten-second-stream",
initialPrompt: "Make the fix and run the burst script.",
});
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await withDirectoryPlugin({
pluginId: "reply-fold-e2e",
source: `import React from "react";
import { Text } from "react-native";
import { z } from "zod";
export default function contribute(client) {
client.addTimelineTransformer({
id: "reply", query: { itemType: "assistant_message" },
transform({ item, phase }) {
if (phase === "streaming") return undefined;
return { items: [{ type: "plugin", kind: "reply", version: 1, data: { text: item.text } }] };
},
});
client.addTimelineRenderer({
kind: "reply", version: 1, schema: z.object({ text: z.string() }),
Component: ({ item }) => <Text testID="plugin-reply">{item.data.text}</Text>,
});
return () => {};
}`,
run: async () => {
await page.setViewportSize({ width: 1280, height: 1000 });
await openAgentRoute(page, agent);
await expectComposerVisible(page);
for (const width of [1280, 390]) {
await page.setViewportSize({ width, height: 1000 });
const summary = page.getByTestId("turn-summary").last().getByRole("button");
const reply = page.getByTestId("plugin-reply").last();
await expect(summary).toBeVisible();
await expect(reply).toContainText("(end of synthetic stream)");
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply")).toBe(0);
await page.screenshot({ path: info.outputPath(`plugin-reply-folded-${width}.png`) });
await summary.click();
await expect
.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply"))
.toBeGreaterThan(0);
await expect(reply).toContainText("(end of synthetic stream)");
await summary.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply")).toBe(0);
}
await page.reload();
await expect(page.getByTestId("turn-summary").last()).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page, "plugin-reply")).toBe(0);
},
});
} finally {
await agent.cleanup();
}
});
test("folds each answered turn behind a summary line that opens it", async ({ page }) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1280, height: 1000 });
@@ -344,7 +407,6 @@ test("opening a turn keeps the received message above it clipped on every frame"
.getByTestId("user-message")
.filter({ has: page.getByRole("button", { name: "Sent by Reviewer", exact: true }) });
await expect(bubble.getByRole("button", { name: "Show more", exact: true })).toBeVisible();
await bubble.scrollIntoViewIfNeeded();
await watchReceivedMessagePreview(page, "Sent by Reviewer");
await page
@@ -8,7 +8,7 @@ import {
openProjectViaDaemon,
} from "../support/helpers/new-workspace";
import { getServerId } from "../support/helpers/server-id";
import { openSettingsHostSection } from "../support/helpers/settings";
import { openCompactSettings, openSettingsHostSection } from "../support/helpers/settings";
import {
expectFooterItemSetting,
expectFooterSeparator,
@@ -42,6 +42,7 @@ import {
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
import { createTempGitRepo } from "../support/helpers/workspace";
import { buildOpenProjectRoute } from "@/utils/host-routes";
const WIDE = { width: 1440, height: 900 };
const COMPACT = { width: 390, height: 844 };
@@ -70,7 +71,112 @@ async function footerClip(page: Page) {
return (await page.locator('[data-testid="sidebar-footer"]:visible').boundingBox())!;
}
async function setPageVisibility(page: Page, visibility: DocumentVisibilityState): Promise<void> {
await page.evaluate((state) => {
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state });
Object.defineProperty(document, "hidden", {
configurable: true,
get: () => state === "hidden",
});
document.dispatchEvent(new Event("visibilitychange"));
}, visibility);
}
test.describe("Usage item", () => {
test("visible summary refreshes every five minutes without opening Usage", async ({ page }) => {
let usedPct = 31;
const usage = await installUsageReportsFixture(page, {
lists: [
() => [
{
...claudeAndCodexReports()[0]!,
report: {
status: "available",
windows: [
{ id: "five_hour", label: "Session", shortLabel: "5h", summary: true, usedPct },
],
},
},
],
],
});
await page.clock.install();
await page.setViewportSize(WIDE);
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h"]);
const startingUrl = page.url();
await qaScreenshot(page, "usage-auto-refresh-initial", { kind: "footer" });
const requestsBeforeRefresh = usage.listRequests();
usedPct = 42;
await page.clock.fastForward(5 * 60_000);
await qaScreenshot(page, "usage-auto-refresh-after-interval", { kind: "footer" });
await expectPinnedUsage(page, ["42% 5h"]);
await expect(usageModal(page)).toHaveCount(0);
await expect(page).toHaveURL(startingUrl);
expect(usage.listRequests()).toEqual([
...requestsBeforeRefresh,
{ forceRefresh: false, reportIds: undefined, agentId: undefined },
]);
await setPageVisibility(page, "hidden");
const requestsBeforeBackgrounding = usage.listRequests();
usedPct = 47;
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeBackgrounding);
await expectPinnedUsage(page, ["42% 5h"]);
await setPageVisibility(page, "visible");
await page.clock.fastForward(5 * 60_000);
await expectPinnedUsage(page, ["47% 5h"]);
expect(usage.listRequests()).toEqual([
...requestsBeforeBackgrounding,
{ forceRefresh: false, reportIds: undefined, agentId: undefined },
]);
await page.setViewportSize(COMPACT);
await expect(usageItem(page)).toHaveCount(0);
const requestsBeforeHiding = usage.listRequests();
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeHiding);
await openCompactSidebar(page);
await expectPinnedUsage(page, ["47% 5h"]);
await expect(page.getByTestId("agent-list-backdrop")).toBeVisible();
usedPct = 53;
await page.clock.fastForward(5 * 60_000);
await expectPinnedUsage(page, ["53% 5h"]);
await expect(usageModal(page)).toHaveCount(0);
await page.setViewportSize(WIDE);
await openSidebarNavSettings(page);
await setFooterItemVisible(page, "usage", false);
await leaveSettings(page);
await expectNoUsageItem(page);
const requestsBeforeDisabling = usage.listRequests();
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeDisabling);
});
test("Usage settings do not poll reports while the sidebar is inactive", async ({ page }) => {
const usage = await installUsageReportsFixture(page, {
lists: [() => claudeAndCodexReports()],
});
await page.clock.install();
await page.setViewportSize(COMPACT);
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await gotoAppShell(page);
await openCompactSettings(page, buildOpenProjectRoute());
await openSettingsHostSection(page, getServerId(), "usage");
await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible();
await expect(usageItem(page)).toHaveCount(0);
const requestsBeforeWaiting = usage.listRequests();
await page.clock.fastForward(5 * 60_000);
expect(usage.listRequests()).toEqual(requestsBeforeWaiting);
await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible();
});
test("both compact footer entry points open Usage as a sheet", async ({ page }) => {
await page.setViewportSize(COMPACT);
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
@@ -0,0 +1,186 @@
import type { Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import {
archiveWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
openProjectViaDaemon,
} from "../support/helpers/new-workspace";
import { getServerId } from "../support/helpers/server-id";
import { expectWorkspaceAbsentFromSidebar } from "../support/helpers/sidebar";
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
import { getWorkspaceTabTestIds } from "../support/helpers/workspace-tabs";
import {
archiveFromRowButton,
createGatedTeardown,
expectDirectoryPresence,
expectHostWorkspaceSettledActive,
outlastUndoWindow,
pressUndoShortcut,
rowArchiveButton,
rowKebab,
undoButton,
workspaceRow,
workspaceTitleWidth,
} from "../support/helpers/workspace-archive-undo";
import {
waitForSidebarHydration,
waitForWorkspaceInSidebar,
} from "../support/helpers/workspace-ui";
test.describe("Workspace archive undo", () => {
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const worktreeDirectories = new Set<string>();
test.describe.configure({ timeout: 120_000 });
test.beforeEach(async () => {
client = await connectNewWorkspaceDaemonClient();
tempRepo = await createTempGitRepo("archive-undo-");
});
test.afterEach(async () => {
for (const directory of worktreeDirectories) {
await archiveWorkspaceFromDaemon(client, directory).catch(() => undefined);
}
worktreeDirectories.clear();
await client?.close().catch(() => undefined);
await tempRepo?.cleanup().catch(() => undefined);
});
async function seedWorktree(page: Page, slug: string) {
await openProjectViaDaemon(client, tempRepo.path);
const worktree = await createWorktreeViaDaemon(client, {
cwd: tempRepo.path,
slug: `${slug}-${Date.now()}`,
});
worktreeDirectories.add(worktree.workspaceDirectory);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await waitForWorkspaceInSidebar(page, {
serverId: getServerId(),
workspaceId: worktree.workspaceId,
});
return worktree;
}
test("the hover archive button overlays the title instead of narrowing it", async ({ page }) => {
const worktree = await seedWorktree(page, "archive-button-overlay");
await page.mouse.move(0, 0);
await expect(rowArchiveButton(page, worktree.workspaceId)).toHaveCount(0);
const restingWidth = await workspaceTitleWidth(page, worktree.workspaceId);
await workspaceRow(page, worktree.workspaceId).hover();
await expect(rowArchiveButton(page, worktree.workspaceId)).toBeVisible();
await expect(page.getByTestId("sidebar-workspace-trailing-scrim")).toBeVisible();
expect(await workspaceTitleWidth(page, worktree.workspaceId)).toBe(restingWidth);
const [buttonBox, kebabBox] = await Promise.all([
rowArchiveButton(page, worktree.workspaceId).boundingBox(),
rowKebab(page, worktree.workspaceId).boundingBox(),
]);
expect(buttonBox && kebabBox && buttonBox.x + buttonBox.width <= kebabBox.x).toBe(true);
});
test("Cmd/Ctrl+Z right after archiving keeps the worktree", async ({ page }) => {
const worktree = await seedWorktree(page, "archive-undo-shortcut");
await archiveFromRowButton(page, worktree.workspaceId);
await pressUndoShortcut(page);
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
await expect(undoButton(page)).toHaveCount(0);
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
// Once the host settled, any remove the canceled archive sent has already arrived.
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
await expectDirectoryPresence(worktree.workspaceDirectory, true);
});
test("Undo after the worktree was removed recreates it", async ({ page }) => {
const worktree = await seedWorktree(page, "archive-undo-recreate");
await archiveFromRowButton(page, worktree.workspaceId);
// Hovering the toast holds it, and with it the undo, open while the archive finishes.
await undoButton(page).hover();
await expectDirectoryPresence(worktree.workspaceDirectory, false);
await undoButton(page).click();
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible({ timeout: 30_000 });
await expectDirectoryPresence(worktree.workspaceDirectory, true);
});
test("hovering Undo keeps the toast open past its deadline, and its close ends the undo", async ({
page,
}) => {
await page.clock.install({ time: Date.now() });
const worktree = await seedWorktree(page, "archive-undo-hold");
await archiveFromRowButton(page, worktree.workspaceId);
await page.getByTestId("workspace-archived-toast").hover({ position: { x: 8, y: 8 } });
await undoButton(page).hover();
await outlastUndoWindow(page);
await expect(undoButton(page)).toBeVisible();
await page.mouse.move(0, 0);
await outlastUndoWindow(page);
await expect(undoButton(page)).toHaveCount(0);
// The toast took the undo with it.
await pressUndoShortcut(page);
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
});
test("opening the workspace while its undo is still running keeps its tabs", async ({ page }) => {
const teardown = createGatedTeardown();
const repo = await createTempGitRepo("archive-undo-tabs-", {
paseoConfig: teardown.paseoConfig,
});
const seedClient = await connectSeedClient();
try {
await openProjectViaDaemon(client, repo.path);
const worktree = await createWorktreeViaDaemon(client, {
cwd: repo.path,
slug: `archive-undo-tabs-${Date.now()}`,
});
worktreeDirectories.add(worktree.workspaceDirectory);
const target = { cwd: worktree.workspaceDirectory, workspaceId: worktree.workspaceId };
const agents = [
await createMockIdleAgent(seedClient, { ...target, title: "first" }),
await createMockIdleAgent(seedClient, { ...target, title: "second" }),
] as const;
await openWorkspaceWithAgents(page, [agents[0], agents[1]]);
const tabsBefore = await getWorkspaceTabTestIds(page);
await archiveFromRowButton(page, worktree.workspaceId);
// The archive has taken its agents down and now parks in teardown.
await teardown.expectStarted();
await undoButton(page).click();
await workspaceRow(page, worktree.workspaceId).click();
for (const agent of agents) {
await expect(page.getByTestId(`workspace-tab-agent_${agent.id}`).first()).toBeVisible();
}
teardown.release();
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
await expect.poll(() => getWorkspaceTabTestIds(page)).toEqual(tabsBefore);
} finally {
teardown.release();
await seedClient.close().catch(() => undefined);
await repo.cleanup().catch(() => undefined);
}
});
test("Cmd/Ctrl+Z without an archive to undo leaves the sidebar alone", async ({ page }) => {
const worktree = await seedWorktree(page, "archive-undo-none");
await pressUndoShortcut(page);
await expect(undoButton(page)).toHaveCount(0);
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
});
});
@@ -509,6 +509,8 @@ check_autocomplete() {
fi
fi
ad wait 'id="composer-autocomplete-popover"' 10000
# Rows replace the popover's loading row once the host lists them; a cold Claude list is slow.
ad wait "text=\"${expected}\"" 30000
# The popover remeasures once keyboard motion settles.
sleep 1
capture_screen "${prefix}.png"
@@ -69,17 +69,23 @@ async function waitFor(name, predicate, timeoutMs = 10_000) {
}
}
// Keyboard motion has settled once two snapshots agree on the composer position.
// Keyboard motion has settled once two snapshots agree on the composer position. The
// popover shows a loading row until the host lists its commands; a cold Claude list is slow.
async function settledPlacement(name, keyboard) {
let previousTop;
return waitFor(name, (snap) => {
const composer = nodeById(snap, "message-input-root");
if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false;
if (keyboardShown(snap) !== keyboard) return false;
const stable = composer.rect.y === previousTop;
previousTop = composer.rect.y;
return stable;
});
return waitFor(
name,
(snap) => {
const composer = nodeById(snap, "message-input-root");
if (!nodeById(snap, "composer-autocomplete-popover") || !composer) return false;
if (nodeById(snap, "composer-autocomplete-loading")) return false;
if (keyboardShown(snap) !== keyboard) return false;
const stable = composer.rect.y === previousTop;
previousTop = composer.rect.y;
return stable;
},
30_000,
);
}
function assertPlacement(name, snap, expected) {
@@ -99,6 +99,7 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
const observedElements = new WeakSet<Element>();
const resizeObserver = new ResizeObserver(record);
const mutationObserver = new MutationObserver(discover);
let performanceObserver: PerformanceObserver | undefined;
function record(): void {
if (!observation.active) return;
@@ -132,6 +133,7 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
if (!observation.active) {
mutationObserver.disconnect();
resizeObserver.disconnect();
performanceObserver?.disconnect();
return;
}
record();
@@ -141,9 +143,16 @@ export async function startCommandCenterLayoutObservation(page: Page): Promise<v
if (typeof PerformanceObserver !== "undefined") {
try {
const performanceObserver = new PerformanceObserver((list) => {
performanceObserver = new PerformanceObserver((list) => {
const panel = document.querySelector(selectors.panel);
for (const entry of list.getEntries()) {
observation.layoutShift += (entry as PerformanceEntry & { value?: number }).value ?? 0;
const shift = entry as PerformanceEntry & {
value: number;
sources: Array<{ node: Node | null }>;
};
if (shift.sources.some((source) => source.node && panel?.contains(source.node))) {
observation.layoutShift += shift.value;
}
}
});
performanceObserver.observe({ type: "layout-shift", buffered: false });
@@ -174,7 +183,54 @@ export async function expectStableCommandCenterLayout(page: Page): Promise<void>
).toBeLessThanOrEqual(1);
}
}
expect(observation.layoutShift, JSON.stringify(observation)).toBeLessThanOrEqual(0.001);
expect(
observation.layoutShift,
`command center layoutShift: ${JSON.stringify(observation)}`,
).toBeLessThanOrEqual(0.001);
}
/** Exercise an unrelated page shift while the real search panel is being observed. */
export async function shiftOutsideCommandCenter(page: Page): Promise<void> {
await shiftLayoutProbe(page.locator("body"));
}
export async function expectLayoutObservationToCatchPanelShift(page: Page): Promise<void> {
await startCommandCenterLayoutObservation(page);
await shiftLayoutProbe(page.getByTestId("command-center-panel"));
await expect(expectStableCommandCenterLayout(page)).rejects.toThrow("command center layoutShift");
}
async function shiftLayoutProbe(parent: Locator): Promise<void> {
const recordedShift = await parent.evaluate(async (container) => {
const sibling = document.createElement("div");
sibling.textContent = "Layout probe";
sibling.style.cssText =
"position:fixed;left:0;top:0;width:300px;height:100px;background:white;z-index:99999";
container.append(sibling);
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
const value = await new Promise<number>((resolve) => {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const shift = entry as PerformanceEntry & {
value: number;
sources: Array<{ node: Node | null }>;
};
if (shift.sources.some((source) => source.node === sibling)) {
observer.disconnect();
resolve(shift.value);
}
}
});
observer.observe({ type: "layout-shift", buffered: false });
sibling.style.left = "400px";
});
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
sibling.remove();
return value;
});
expect(recordedShift, "the browser recorded a substantial probe shift").toBeGreaterThan(0.001);
}
export async function expectOneLineTruncatedFileResult(
@@ -53,3 +53,192 @@ export async function expectNoCollapsedComposerToolbarFrame(page: Page): Promise
const collapsed = frames.filter((frame) => frame.labels.some((label) => label === ""));
expect(collapsed, `collapsed toolbar frames: ${JSON.stringify(collapsed)}`).toEqual([]);
}
/** What the folded model control's labels show, read from the DOM. */
export interface ModelTriggerLabelState {
thinkingShown: boolean;
modelShown: boolean;
modelText: string;
modelTruncated: boolean;
}
declare global {
interface Window {
__modelTriggerFrames?: ModelTriggerLabelState[];
}
}
function readModelTriggerLabelStateInPage(): ModelTriggerLabelState {
const visible = (node: Element) => node.getClientRects().length > 0;
const model = Array.from(
document.querySelectorAll('[data-testid="combined-model-selector-model-label"]'),
).find(visible);
const thinking = Array.from(
document.querySelectorAll('[data-testid="combined-model-selector-thinking-label"]'),
).find(visible);
const shown = (node: Element | null): boolean => {
for (let current = node; current; current = current.parentElement) {
if (Number(getComputedStyle(current).opacity) === 0) return false;
}
return true;
};
const modelShown = model !== undefined && shown(model);
const modelText = model?.textContent ?? "";
return {
modelShown,
modelText,
thinkingShown: modelShown && thinking !== undefined,
modelTruncated:
model !== undefined && (modelText.endsWith("…") || model.scrollWidth > model.clientWidth),
};
}
/** Records the folded model control's labels on every painted frame from page load. */
export async function recordModelTriggerFramesFromLoad(page: Page): Promise<void> {
await page.addInitScript(`(() => {
const read = ${readModelTriggerLabelStateInPage.toString()};
const frames = [];
window.__modelTriggerFrames = frames;
const tick = () => {
frames.push(read());
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
})()`);
}
/** The model control's labels once two reads 100ms apart agree. */
export async function readSettledModelTriggerLabels(page: Page): Promise<ModelTriggerLabelState> {
let previous = await page.evaluate(readModelTriggerLabelStateInPage);
for (;;) {
await page.waitForTimeout(100);
const next = await page.evaluate(readModelTriggerLabelStateInPage);
if (JSON.stringify(next) === JSON.stringify(previous)) return next;
previous = next;
}
}
/** The labels at the widths where the folded model control changes what it shows. */
export interface ModelTriggerLabelSweep {
/** The labels at the widest width. */
widest: ModelTriggerLabelState & { width: number };
/** The first width at which the thinking label is hidden. */
thinkingHidden: ModelTriggerLabelState & { width: number };
/** The first width at which the model label is truncated. */
modelTruncated: ModelTriggerLabelState & { width: number };
}
/**
* Narrows the viewport step by step and records where the thinking label disappears and where the
* model label first truncates.
*/
export async function sweepModelTriggerLabels(
page: Page,
widths: { from: number; to: number; step: number; height: number },
): Promise<ModelTriggerLabelSweep> {
const steps: (ModelTriggerLabelState & { width: number })[] = [];
for (let width = widths.from; width >= widths.to; width -= widths.step) {
await page.setViewportSize({ width, height: widths.height });
const labels = await readSettledModelTriggerLabels(page);
steps.push({ ...labels, width });
if (labels.modelTruncated || !labels.modelShown) break;
}
const thinkingHidden = steps.find((step) => !step.thinkingShown);
const modelTruncated = steps.find((step) => step.modelTruncated);
if (!thinkingHidden || !modelTruncated) {
throw new Error(`the sweep never hid the thinking label and truncated the model label`);
}
return { widest: steps[0], thinkingHidden, modelTruncated };
}
/** No frame recorded so far painted a truncated model label beside the thinking label. */
export async function expectNoFrameTruncatesModelBesideThinking(page: Page): Promise<void> {
const frames = await page.evaluate(() => window.__modelTriggerFrames ?? []);
expect(frames.some((frame) => frame.modelShown)).toBe(true);
const wrong = frames
.map((frame, index) => ({ ...frame, index }))
.filter((frame) => frame.thinkingShown && frame.modelTruncated);
expect(wrong, `of ${frames.length} frames`).toEqual([]);
}
/** Where the composer toolbar's ends sit against the composer, in CSS pixels. */
export interface ComposerToolbarRails {
/** Where the text column starts. */
textLeft: number;
/** Where the toolbar's leftmost ink starts. */
inkLeft: number;
/** How far the trailing disc sits from the composer's right edge. */
discRightInset: number;
/** How far the trailing disc sits from the composer's bottom edge. */
discBottomInset: number;
}
/**
* Reads where the toolbar's ink starts against the text column and where the trailing disc sits
* in the composer's corner. A control's ink is its filled disc when it has one, otherwise its
* glyph's stroked geometry.
*/
export async function readComposerToolbarRails(page: Page): Promise<ComposerToolbarRails> {
const root = page.locator('[data-testid="message-input-root"]:visible').first();
return root.evaluate((element) => {
const textarea = element.querySelector("textarea");
if (!textarea) throw new Error("composer has no text input");
const composerRect = element.getBoundingClientRect();
const textLeft =
textarea.getBoundingClientRect().left +
Number.parseFloat(getComputedStyle(textarea).paddingLeft);
const isFilled = (node: Element) => {
const color = getComputedStyle(node).backgroundColor;
return color !== "" && color !== "transparent" && !/rgba\(.*,\s*0\)$/.test(color);
};
const glyphInkLeft = (svg: SVGSVGElement) => {
let left = Number.POSITIVE_INFINITY;
for (const shape of Array.from(
svg.querySelectorAll<SVGGraphicsElement>("path, circle, rect, line, polyline"),
)) {
const matrix = shape.getScreenCTM();
if (!matrix) continue;
const box = shape.getBBox();
const stroke = Number.parseFloat(getComputedStyle(shape).strokeWidth || "0");
left = Math.min(left, matrix.a * (box.x - stroke / 2) + matrix.e);
}
return left;
};
const band = composerRect.bottom - 64;
let inkLeft = Number.POSITIVE_INFINITY;
let trailingDisc: DOMRect | null = null;
for (const control of Array.from(element.querySelectorAll('[role="button"]'))) {
const rect = control.getBoundingClientRect();
if (rect.width === 0 || rect.top < band) continue;
const disc = [control, ...Array.from(control.querySelectorAll("div"))].find(isFilled);
const svg = control.querySelector("svg");
if (disc) {
const discRect = disc.getBoundingClientRect();
inkLeft = Math.min(inkLeft, discRect.left);
if (!trailingDisc || discRect.right > trailingDisc.right) trailingDisc = discRect;
} else if (svg) {
inkLeft = Math.min(inkLeft, glyphInkLeft(svg));
}
}
if (!trailingDisc) throw new Error("composer toolbar has no trailing disc");
return {
textLeft,
inkLeft,
discRightInset: composerRect.right - trailingDisc.right,
discBottomInset: composerRect.bottom - trailingDisc.bottom,
};
});
}
/**
* The toolbar's left ink sits on the text column's edge, and its trailing disc nests into the
* corner, as far from the right edge as from the bottom edge. Within half a pixel.
*/
export async function expectComposerToolbarOnTextRails(page: Page): Promise<void> {
const rails = await readComposerToolbarRails(page);
// A precision of 0 digits passes within half a pixel.
expect(rails.inkLeft).toBeCloseTo(rails.textLeft, 0);
expect(rails.discRightInset).toBeCloseTo(rails.discBottomInset, 0);
}
+25 -11
View File
@@ -22,15 +22,27 @@ export async function addOfflineHostAndReload(
page: Page,
input: { serverId: string; label: string; primaryLabel?: string },
): Promise<void> {
const offlineHost = buildSeededHost({
serverId: input.serverId,
label: input.label,
endpoint: "127.0.0.1:59999",
nowIso: new Date().toISOString(),
});
await addOfflineHostsAndReload(page, [input], { primaryLabel: input.primaryLabel });
}
/** Adds several unreachable hosts with one reload. See `addOfflineHostAndReload`. */
export async function addOfflineHostsAndReload(
page: Page,
hosts: Array<{ serverId: string; label: string }>,
options: { primaryLabel?: string } = {},
): Promise<void> {
const nowIso = new Date().toISOString();
const offlineHosts = hosts.map((host) =>
buildSeededHost({
serverId: host.serverId,
label: host.label,
endpoint: "127.0.0.1:59999",
nowIso,
}),
);
await page.evaluate(
({ host, keys, primaryLabel }) => {
({ hosts: added, keys, primaryLabel }) => {
const nonce = localStorage.getItem(keys.nonce);
if (!nonce) {
throw new Error("Expected the e2e seed nonce before overriding the host registry.");
@@ -40,20 +52,22 @@ export async function addOfflineHostAndReload(
if (primaryLabel && registry[0]) {
registry[0].label = primaryLabel;
}
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
for (const host of added) {
if (!registry.some((entry) => entry.serverId === host.serverId)) {
registry.push(host);
}
}
localStorage.setItem(keys.registry, JSON.stringify(registry));
localStorage.setItem(keys.disableSeedOnce, nonce);
},
{
host: offlineHost,
hosts: offlineHosts,
keys: {
registry: REGISTRY_KEY,
nonce: SEED_NONCE_KEY,
disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
primaryLabel: input.primaryLabel,
primaryLabel: options.primaryLabel,
},
);
@@ -134,7 +134,7 @@ export default function contribute(client) {
}`;
}
async function withDirectoryPlugin(options: {
export async function withDirectoryPlugin(options: {
pluginId: string;
source: string;
run: () => Promise<void>;
@@ -157,8 +157,11 @@ export interface SeedDaemonClient {
sendAgentMessage(
agentId: string,
text: string,
options?: { sourceAgentId?: string },
options?: { sourceAgentId?: string; activeTurnBehavior?: "interrupt" | "steer" | "queue" },
): Promise<void>;
listAgentBackgroundWork(
agentId: string,
): Promise<import("@getpaseo/client").PaseoAgentBackgroundWorkItem[]>;
waitForFinish(
agentId: string,
timeout?: number,
@@ -0,0 +1,127 @@
import { expect, type Locator, type Page } from "@playwright/test";
/** Every footer control that can sit in the icon line, caret included. */
const FOOTER_LINE_TEST_IDS = [
"sidebar-add-project",
"sidebar-usage-icon",
"sidebar-hosts-trigger",
"sidebar-help",
"sidebar-footer-overflow",
"sidebar-settings",
];
// Browser layout boxes include floating-point rounding, even for whole-pixel styles.
const GEOMETRY_TOLERANCE = 0.01;
/** Open the app with the docked sidebar at `width`, as if the user had dragged it there. */
export async function seedDockedSidebarWidth(page: Page, width: number): Promise<void> {
await page.addInitScript((sidebarWidth) => {
localStorage.setItem("panel-state", JSON.stringify({ version: 17, state: { sidebarWidth } }));
}, width);
}
/**
* From the first frame, record any frame in which a footer button reaches past its row, so a row
* laid out before it measured shows up even if it lasted one frame.
*/
export async function watchFooterClipping(page: Page): Promise<void> {
await page.addInitScript((testIDs) => {
const clippedFrames: string[] = [];
Object.assign(window, { __footerClippedFrames: clippedFrames });
const sample = () => {
for (const row of document.querySelectorAll('[data-testid="sidebar-footer-bottom-line"]')) {
const rowRect = row.getBoundingClientRect();
if (rowRect.width === 0) continue;
for (const testID of testIDs) {
const button = row.querySelector(`[data-testid="${testID}"]`);
if (button && button.getBoundingClientRect().right > rowRect.right + 0.5) {
clippedFrames.push(testID);
}
}
}
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
}, FOOTER_LINE_TEST_IDS);
}
export async function expectFooterNeverClipped(page: Page): Promise<void> {
const clipped = await page.evaluate(
() => (window as unknown as { __footerClippedFrames: string[] }).__footerClippedFrames,
);
expect(clipped).toEqual([]);
}
function footerLine(page: Page): Locator {
return page.locator('[data-testid="sidebar-footer-bottom-line"]:visible');
}
export function footerOverflowMenu(page: Page): Locator {
return page.getByTestId("sidebar-footer-overflow-menu");
}
/**
* The icon line shows exactly `testIDs`, in order, each `controlSize` square and wholly inside the
* line's box, so no button is cut off by the sidebar's edge.
*/
export async function expectFooterLine(
page: Page,
input: { testIDs: string[]; controlSize: number },
): Promise<void> {
const line = footerLine(page);
await expect(line).toHaveCount(1);
await expect
.poll(async () =>
line
.locator(FOOTER_LINE_TEST_IDS.map((testID) => `[data-testid="${testID}"]`).join(","))
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-testid"))),
)
.toEqual(input.testIDs);
const { row, buttons } = await line.evaluate((element, testIDs) => {
const rect = (node: Element) => {
const { x, width, height } = node.getBoundingClientRect();
return { x, width, height };
};
return {
row: rect(element),
buttons: testIDs.map((testID) => rect(element.querySelector(`[data-testid="${testID}"]`)!)),
};
}, input.testIDs);
for (const button of buttons) {
expect(Math.abs(button.width - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE);
expect(Math.abs(button.height - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE);
expect(button.x).toBeGreaterThanOrEqual(row.x - GEOMETRY_TOLERANCE);
expect(button.x + button.width).toBeLessThanOrEqual(row.x + row.width + GEOMETRY_TOLERANCE);
}
}
export async function openFooterOverflow(page: Page): Promise<void> {
await footerLine(page).getByTestId("sidebar-footer-overflow").click();
await expect(footerOverflowMenu(page)).toBeVisible();
}
/** The overflow menu lists exactly these actions, in row order. */
export async function expectFooterOverflowActions(page: Page, names: string[]): Promise<void> {
await expect(footerOverflowMenu(page).getByRole("menuitem")).toHaveText(names);
}
export async function chooseFooterOverflowAction(page: Page, name: string): Promise<void> {
await footerOverflowMenu(page).getByRole("menuitem", { name }).click();
}
/** Every row of the open overflow menu, and of the page it opened, is inside the viewport's width. */
export async function expectFooterMenusInsideViewport(page: Page): Promise<void> {
const viewport = page.viewportSize()!;
const rows = page.getByRole("menuitem");
await expect(rows.first()).toBeVisible();
const boxes = await rows.evaluateAll((elements) =>
elements.map((element) => {
const { x, width } = element.getBoundingClientRect();
return { x, width };
}),
);
for (const box of boxes) {
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width);
}
}
@@ -0,0 +1,97 @@
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Page } from "@playwright/test";
import { expect } from "../fixtures";
import type { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { getServerId } from "./server-id";
import { expectWorkspaceAbsentFromSidebar } from "./sidebar";
type DaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const UNDO_SHORTCUT = process.platform === "darwin" ? "Meta+Z" : "Control+Z";
/** The archived toast's lifetime, from `WORKSPACE_ARCHIVE_UNDO_WINDOW_MS`. */
const UNDO_WINDOW_MS = 6000;
export function workspaceRow(page: Page, workspaceId: string) {
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
}
export function rowArchiveButton(page: Page, workspaceId: string) {
return page.getByTestId(`sidebar-workspace-archive-button-${getServerId()}:${workspaceId}`);
}
export function rowKebab(page: Page, workspaceId: string) {
return page.getByTestId(`sidebar-workspace-kebab-${getServerId()}:${workspaceId}`);
}
export function undoButton(page: Page) {
return page.getByRole("button", { name: /^Undo/ });
}
export async function workspaceTitleWidth(page: Page, workspaceId: string): Promise<number> {
const box = await page
.getByTestId(`sidebar-workspace-title-${getServerId()}:${workspaceId}`)
.boundingBox();
if (!box) throw new Error(`Title of ${workspaceId} is not laid out`);
return box.width;
}
/** Archives a workspace with its row's hover button and waits for the undo toast. */
export async function archiveFromRowButton(page: Page, workspaceId: string): Promise<void> {
page.once("dialog", (dialog) => void dialog.accept());
await workspaceRow(page, workspaceId).hover();
await rowArchiveButton(page, workspaceId).click();
await expectWorkspaceAbsentFromSidebar(page, workspaceId);
await expect(undoButton(page)).toBeVisible();
}
export async function pressUndoShortcut(page: Page): Promise<void> {
await page.keyboard.press(UNDO_SHORTCUT);
}
/** Moves the page clock past the moment an unheld archived toast would have closed. */
export async function outlastUndoWindow(page: Page): Promise<void> {
await page.clock.fastForward(UNDO_WINDOW_MS + 1000);
}
/** Waits until the host lists the workspace as active with no archive running on it. */
export async function expectHostWorkspaceSettledActive(
client: DaemonClient,
workspaceId: string,
): Promise<void> {
await expect
.poll(
async () => {
const { entries } = await client.fetchWorkspaces();
const entry = entries.find((candidate) => candidate.id === workspaceId);
return entry ? { active: true, archiving: Boolean(entry.archivingAt) } : null;
},
{ timeout: 30_000 },
)
.toEqual({ active: true, archiving: false });
}
export async function expectDirectoryPresence(directory: string, present: boolean): Promise<void> {
await expect.poll(() => existsSync(directory), { timeout: 30_000 }).toBe(present);
}
export interface GatedTeardown {
/** The `paseo.json` config whose worktree teardown parks until released. */
paseoConfig: Record<string, unknown>;
expectStarted(): Promise<void>;
release(): void;
}
/** A worktree teardown that parks an archive mid-flight, so an undo lands while it runs. */
export function createGatedTeardown(): GatedTeardown {
const gateDir = mkdtempSync(path.join(tmpdir(), "archive-undo-gate-"));
const startedPath = path.join(gateDir, "started");
const releasePath = path.join(gateDir, "release");
const teardown = `node -e "const fs=require('fs');fs.writeFileSync('${startedPath}','started');const timer=setInterval(()=>{if(fs.existsSync('${releasePath}'))clearInterval(timer)},20)"`;
return {
paseoConfig: { worktree: { teardown: [teardown] } },
expectStarted: () => expectDirectoryPresence(startedPath, true),
release: () => writeFileSync(releasePath, "release\n"),
};
}
@@ -54,6 +54,28 @@ using namespace facebook::react;
[[RCTComponentViewFactory currentComponentViewFactory] registerComponentViewClass:self];
}
// iOS 26 blurs a scroll view's edges and places the blur in the scroll view's own coordinates,
// ignoring transforms. The keyboard lifts the chat with a translateY, so on iPhones the blur lands
// over the readable chat and stays until the next scroll (#7883). React Native hides it for
// inverted lists for the same reason (https://github.com/react/react-native/pull/58684). The app
// draws its own header fade, so every edge is hidden; hiding only top and bottom leaves the side
// effects, which also cover a transformed list on device.
- (instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0
if (@available(iOS 26.0, *)) {
UIScrollView *scrollView = self.scrollView;
scrollView.topEdgeEffect.hidden = YES;
scrollView.bottomEdgeEffect.hidden = YES;
scrollView.leftEdgeEffect.hidden = YES;
scrollView.rightEdgeEffect.hidden = YES;
}
#endif
}
return self;
}
- (void)mountingTransactionWillMount:(const MountingTransaction &)transaction
withSurfaceTelemetry:(const SurfaceTelemetry &)surfaceTelemetry
{
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.15.2",
"version": "0.15.3",
"private": true,
"main": "index.ts",
"scripts": {
@@ -1,3 +1,5 @@
import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useFetchQuery } from "@/data/query";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -75,6 +77,7 @@ export function useAgentCommandsQuery({
}),
);
const queryClient = useQueryClient();
const query = useFetchQuery({
queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }),
queryFn: async () => {
@@ -101,10 +104,20 @@ export function useAgentCommandsQuery({
// isLoading is true when fetching and no data yet
const isLoading = query.isPending || query.isLoading;
// Reset rather than refetch: a refetch keeps the error status while it runs, and the menu
// shows loading for the retry.
const retry = useCallback(() => {
void queryClient.resetQueries({
queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }),
exact: true,
});
}, [agentId, draftConfig, queryClient, serverId]);
return {
commands: query.data ?? [],
isLoading,
isError: query.isError && query.data === undefined,
error: query.error,
retry,
};
}
+1
View File
@@ -18,6 +18,7 @@ import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol
export interface AgentControlIconProps {
size: number;
color: string;
strokeWidth?: number;
}
export type AgentControlIcon = ComponentType<AgentControlIconProps>;
+19 -9
View File
@@ -1,4 +1,6 @@
import { useShallow } from "zustand/shallow";
import { useTranslation } from "react-i18next";
import { i18n } from "@/i18n/i18next";
import { useProviderIcon } from "@/components/provider-icons";
import type { PanelDescriptor } from "@/panels/panel-registry";
import { useSessionStore, selectAgentTurnPresentation, type Agent } from "@/stores/session-store";
@@ -33,6 +35,7 @@ export function useAgentDescriptor(
target: { kind: "agent"; agentId: string },
context: { serverId: string },
): PanelDescriptor {
useTranslation();
const descriptorState = useSessionStore(
useShallow((state) => {
const session = state.sessions[context.serverId];
@@ -42,6 +45,7 @@ export function useAgentDescriptor(
provider: agent?.provider ?? "codex",
title: agent?.title ?? null,
status: agent?.status ?? null,
backgroundWork: agent?.backgroundWork,
pendingPermissionCount: agent?.pendingPermissions.length ?? 0,
requiresAttention: agent?.requiresAttention ?? false,
attentionReason: agent?.attentionReason ?? null,
@@ -57,6 +61,7 @@ export function buildAgentDescriptor(input: {
provider: Agent["provider"];
title: string | null;
status: Agent["status"] | null;
backgroundWork?: Agent["backgroundWork"];
pendingPermissionCount: number;
requiresAttention: boolean;
attentionReason: Agent["attentionReason"];
@@ -65,20 +70,25 @@ export function buildAgentDescriptor(input: {
}): PanelDescriptor {
const provider = input.provider;
const label = resolveAgentLabel(input.title);
const statusBucket = input.status
? deriveSidebarStateBucket({
status: input.isTurnActive ? "running" : input.status,
backgroundWork: input.backgroundWork,
pendingPermissionCount: input.pendingPermissionCount,
requiresAttention: input.requiresAttention,
attentionReason: input.attentionReason,
})
: null;
return {
label: label ?? "",
subtitle: `${formatProviderLabel(provider)} agent`,
subtitle:
statusBucket === "running" && !input.isTurnActive && input.status !== "running"
? i18n.t("message.backgroundWork.running")
: `${formatProviderLabel(provider)} agent`,
tooltip: label ?? `${formatProviderLabel(provider)} agent`,
titleState: label ? "ready" : "loading",
icon: input.icon,
statusBucket: input.status
? deriveSidebarStateBucket({
status: input.isTurnActive ? "running" : input.status,
pendingPermissionCount: input.pendingPermissionCount,
requiresAttention: input.requiresAttention,
attentionReason: input.attentionReason,
})
: null,
statusBucket,
};
}
@@ -65,7 +65,6 @@ interface BottomAnchorControllerDriver {
pendingVerification: AttemptContext | null;
blockedReason: BottomAnchorBlockedReason | null;
};
resetForAgent: () => void;
applyRouteRequest: (request: BottomAnchorRouteRequest | null) => void;
requestLocalAnchor: (request: BottomAnchorLocalRequest) => void;
beginUserScroll: () => void;
@@ -480,17 +479,6 @@ function createBottomAnchorControllerDriver(
blockedReason,
};
},
resetForAgent() {
lastRouteRequestKey = null;
pendingRequest = null;
blockedReason = null;
cancelPendingAttempt();
stickyMeasurementRevision = 0;
lastVerifiedStickyMeasurementRevision = 0;
isUserScrollActive = false;
mode = "sticky-bottom";
input.onModeChange("sticky-bottom");
},
applyRouteRequest(request) {
if (!request) {
return;
@@ -741,10 +729,6 @@ export function useBottomAnchorController(input: {
});
}
useEffect(() => {
driverRef.current?.resetForAgent();
}, [input.agentId]);
useEffect(() => {
driverRef.current?.applyRouteRequest(input.routeRequest);
}, [input.routeRequest]);
@@ -129,7 +129,8 @@ function mountList(heldRowId: string) {
}
return position - state.scroll;
};
return { render, scrollTo, heldRowTop };
const rowSize = (rowId: string) => listRef.current?.getState().sizes.get(rowId);
return { render, scrollTo, heldRowTop, rowSize };
}
it("anchors a data change on a held row whose top is above the viewport", async () => {
@@ -169,3 +170,21 @@ it("anchors a size change on a held row whose top is above the viewport", async
expect(list.heldRowTop()).toBe(-20);
});
it("keeps measured row sizes while the list is not laid out", async () => {
// A hidden retained tab is display: none. Its rows still render, and a row measured there
// reports 0 x 0. When the tab shows again, a row whose frame did not change gets no new layout
// event, so a 0 recorded while hidden would stay and the next row would overlap it.
const rows: Row[] = Array.from({ length: 3 }, (_, index) => ({ id: `row-${index}`, height: 40 }));
const list = mountList("row-0");
list.render(rows);
await wait(150);
expect(rows.map((row) => list.rowSize(row.id))).toEqual([40, 40, 40]);
host.style.display = "none";
await wait(150);
list.render(rows.map((row) => ({ ...row })));
await wait(150);
expect(rows.map((row) => list.rowSize(row.id))).toEqual([40, 40, 40]);
});
@@ -593,6 +593,92 @@ describe("timeline presentation", () => {
});
});
it("folds work before a plugin-rendered reply and leaves the reply visible", () => {
const timestamp = new Date("2026-01-01T00:00:00.000Z");
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Calculate", timestamp },
toolCall("one", "Bash"),
{ kind: "assistant_message", id: "reply", text: "$$x^2$$", timestamp },
];
const result = createStreamPresentation()({
...presentationOptions,
transform: installedTransform(installProbe("assistant_message", "phase === 'complete'")),
tail,
head: [],
display: "collapseTurns",
isTurnActive: false,
});
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "probe-plugin/reply/0"]);
expect([...result.turns.turnsByHostId.keys()]).toEqual(["prompt"]);
expect(pluginData(result.tail)).toEqual([{ text: "$$x^2$$", phase: "complete" }]);
});
it("keeps streaming replies native, then folds work without losing manual expansion", () => {
const timestamp = new Date("2026-01-01T00:00:00.000Z");
const work: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Calculate", timestamp },
toolCall("one", "Bash"),
];
const reply: StreamItem = {
kind: "assistant_message",
id: "reply",
text: "$$x^2$$",
timestamp,
};
const input = {
...presentationOptions,
transform: installedTransform(installProbe("assistant_message", "phase === 'complete'")),
display: "collapseTurns" as const,
};
const present = createStreamPresentation();
const live = present({ ...input, tail: work, head: [reply], isTurnActive: true });
expect(rows(live).map((item) => item.id)).toEqual(["prompt", "tool-one", "reply"]);
const completed = { ...input, tail: [...work, reply], head: [], isTurnActive: false };
expect(present(completed).tail.map((item) => item.id)).toEqual([
"prompt",
"probe-plugin/reply/0",
]);
const expandedTurnHostIds = new Set(["prompt"]);
const opened = present({ ...completed, expandedTurnHostIds });
expect(opened.tail.map((item) => item.id)).toEqual([
"prompt",
"tool-one",
"probe-plugin/reply/0",
]);
expect(opened.turns.nestedItemIds.has("probe-plugin/reply/0")).toBe(false);
expect(present({ ...completed, expandedTurnHostIds }).tail).toEqual(opened.tail);
expect(present(completed).tail.map((item) => item.id)).toEqual([
"prompt",
"probe-plugin/reply/0",
]);
});
it.each(["full", "collapseToolCalls"] as const)(
"leaves plugin replies and individual work visible in %s",
(display) => {
const timestamp = new Date("2026-01-01T00:00:00.000Z");
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Calculate", timestamp },
toolCall("one", "Bash"),
{ kind: "assistant_message", id: "reply", text: "$$x^2$$", timestamp },
];
const result = createStreamPresentation()({
...presentationOptions,
transform: installedTransform(installProbe("assistant_message")),
tail,
head: [],
display,
isTurnActive: false,
});
expect(result.tail.map((item) => item.id)).toEqual([
"prompt",
"tool-one",
"probe-plugin/reply/0",
]);
expect(result.turns.turnsByHostId.size).toBe(0);
},
);
it("opening a turn reveals individual calls rather than another tool group", () => {
const present = createStreamPresentation();
const timestamp = new Date();
@@ -137,9 +137,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
} = props;
const { renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary } = renderers;
const listRef = useRef<LegendListRef>(null);
// LegendList places the rows of each agent once, behind a hidden reveal (`dataKey`).
const [placedAgentId, setPlacedAgentId] = useState<string | null>(null);
const isListPlaced = placedAgentId === agentId;
// LegendList places the rows once, behind a hidden reveal, when the list mounts.
const [isListPlaced, setIsListPlaced] = useState(false);
const isListPlacedRef = useRef(isListPlaced);
isListPlacedRef.current = isListPlaced;
const streamViewportMetricsRef = useRef({
@@ -384,23 +383,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
);
useEffect(() => {
streamViewportMetricsRef.current = {
containerKey: "native-virtualized",
contentHeight: 0,
viewportWidth: 0,
viewportHeight: 0,
offsetY: 0,
viewportMeasuredForKey: null,
contentMeasuredForKey: null,
};
scrollOffsetYRef.current = 0;
isUserScrollActiveRef.current = false;
clearPendingUserScrollEnd();
disclosureHold.cancel();
historyStartReadyRef.current = false;
const initialHistoryStartState = createHistoryStartPaginationState();
historyStartPaginationStateRef.current = initialHistoryStartState;
setHistoryStartPaginationState(initialHistoryStartState);
const frame = requestAnimationFrame(() => {
historyStartReadyRef.current = true;
evaluateHistoryStart();
@@ -411,7 +393,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [agentId, clearPendingUserScrollEnd, disclosureHold, evaluateHistoryStart]);
}, [clearPendingUserScrollEnd, evaluateHistoryStart]);
useEffect(() => {
const handle: StreamViewportHandle = {
@@ -623,7 +605,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const handleListPlaced = useStableEvent(() => {
isListPlacedRef.current = true;
setPlacedAgentId(agentId);
setIsListPlaced(true);
streamViewportMetricsRef.current = {
...streamViewportMetricsRef.current,
contentMeasuredForKey: "native-virtualized",
@@ -711,7 +693,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
onContentSizeChange={handleContentSizeChange}
onItemSizeChanged={handleItemSizeChanged}
onReady={handleListPlaced}
dataKey={agentId}
maintainVisibleContentPosition={maintainVisibleContentPosition}
renderScrollComponent={renderScrollComponent}
recycleItems={false}
+2 -10
View File
@@ -839,10 +839,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
]);
useEffect(() => {
const initialHistoryStartState = createHistoryStartPaginationState();
historyStartPaginationStateRef.current = initialHistoryStartState;
setHistoryStartPaginationState(initialHistoryStartState);
readingAnchor.reset();
const frame = window.requestAnimationFrame(() => {
historyStartReadyRef.current = true;
evaluateHistoryStart();
@@ -853,7 +849,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
historyStartSettleSchedulerRef.current?.cancel();
historyStartSettleSchedulerRef.current = null;
};
}, [evaluateHistoryStart, props.agentId, readingAnchor]);
}, [evaluateHistoryStart]);
useLayoutEffect(() => {
if (!isActiveRef.current || !isActivationReady) {
@@ -1395,11 +1391,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): StreamStrategy {
return createStreamStrategy({
render: (renderInput) => (
<WebStreamViewport
key={renderInput.agentId}
{...renderInput}
isMobileBreakpoint={input.isMobileBreakpoint}
/>
<WebStreamViewport {...renderInput} isMobileBreakpoint={input.isMobileBreakpoint} />
),
orderTailReverse: false,
orderHeadReverse: false,
@@ -12,6 +12,21 @@ function reply(id: string): StreamItem {
return { kind: "assistant_message", id, text: id, timestamp: at };
}
function pluginReply(sourceId: string, part = "0"): StreamItem {
return {
kind: "plugin",
id: `math/${sourceId}/${part}`,
sourceId,
sourceKind: "assistant_message",
timestamp: at,
pluginId: "math",
pluginItemId: `${sourceId}/${part}`,
itemKind: "formula",
version: 1,
data: { text: "$$x^2$$" },
};
}
function thought(id: string): StreamItem {
return { kind: "thought", id, text: id, timestamp: at, status: "ready" };
}
@@ -49,6 +64,44 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
const ids = (items: StreamItem[]) => items.map((item) => item.id);
describe("collapseTurns", () => {
it("keeps every card of the final reply outside the folded turn", () => {
const items = [
user("prompt"),
reply("acknowledgment"),
shell("call"),
pluginReply("final", "left"),
pluginReply("final", "right"),
];
const result = collapse(items);
expect(ids(result.tail)).toEqual(["prompt", "math/final/left", "math/final/right"]);
expect(result.hostIdByFoldedMessageId.get("acknowledgment")).toBe("prompt");
expect(result.hostIdByFoldedMessageId.has("final")).toBe(false);
const expanded = collapse(items, { expandedHostIds: new Set(["prompt"]) });
expect(expanded.tail).toEqual(items);
expect([...expanded.nestedItemIds]).toEqual(["acknowledgment", "call"]);
});
it("folds intermediate plugin replies even without tool calls", () => {
const result = collapse([user("prompt"), pluginReply("intermediate"), pluginReply("final")]);
expect(ids(result.tail)).toEqual(["prompt", "math/final/0"]);
expect(result.hostIdByFoldedMessageId.get("intermediate")).toBe("prompt");
});
it.each([undefined, "thought", "tool_call"] as const)(
"does not treat a plugin row with source kind %s as a reply",
(sourceKind) => {
const card = { ...pluginReply("card"), sourceKind };
const items = [user("prompt"), shell("call"), card];
expect(collapse(items).tail).toEqual(items);
expect(collapse(items).turnsByHostId.size).toBe(0);
},
);
it("keeps a plugin-rendered reply and its work unfolded while the turn is open", () => {
const items = [user("prompt"), shell("call"), pluginReply("final")];
expect(collapse(items, { isLatestTurnOpen: true }).tail).toEqual(items);
});
// A history row renders again only when its item changes, so a prompt that is already retained
// when its turn closes needs its row revised to show the summary line.
it("reports the hosts whose summary line appeared, changed or went away", () => {
@@ -100,9 +100,25 @@ function staysVisibleWhenCollapsed(item: StreamItem): boolean {
return isTurnTrigger(item) || item.kind === "compaction";
}
function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number {
function isAssistantReply(item: StreamItem): boolean {
return (
item.kind === "assistant_message" ||
(item.kind === "plugin" && item.sourceKind === "assistant_message")
);
}
function findLastReplyIndex(tail: StreamItem[], start: number, end: number): number {
for (let index = end - 1; index >= start; index -= 1) {
if (tail[index]?.kind === "assistant_message") return index;
const item = tail[index]!;
if (!isAssistantReply(item)) continue;
const messageId = getStreamItemMessageId(item);
// A transformer can split one reply into several cards; keep the whole reply visible.
while (index > start) {
const previous = tail[index - 1]!;
if (!isAssistantReply(previous) || getStreamItemMessageId(previous) !== messageId) break;
index -= 1;
}
return index;
}
return -1;
}
@@ -125,7 +141,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
const flushRun = (runEnd: number) => {
const host = tail[runStart - 1];
const isClosed = runEnd < tail.length || !input.isLatestTurnOpen;
const replyIndex = findLastAssistantIndex(tail, runStart, runEnd);
const replyIndex = findLastReplyIndex(tail, runStart, runEnd);
if (
!host ||
(host.kind !== "user_message" && host.kind !== "tool_call") ||
@@ -137,7 +153,7 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
}
const folded = tail.slice(runStart, replyIndex);
const summary = summarizeTurn(folded, input.getGroupedCalls);
if (!hasWork(summary) && !folded.some((item) => item.kind === "assistant_message")) {
if (!hasWork(summary) && !folded.some(isAssistantReply)) {
output.push(...tail.slice(runStart, runEnd));
return;
}
+16 -4
View File
@@ -23,6 +23,10 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const workingIndicatorColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
export const TURN_FOOTER_BOTTOM_SPACING = SPACING[8];
// Every footer variant (running, completed with Copy, completed with only its duration) gets
// this content height, so a turn ending swaps the footer's controls without moving the rows
// above it.
const TURN_FOOTER_CONTENT_HEIGHT = 24;
export type TurnContentStrategy = StreamStrategy;
export type AssistantTurnForkHandler = (input: {
@@ -208,7 +212,11 @@ function CompletedTurnFooter({
function TurnFooterRow({ children }: { children: ReactNode }) {
const rowStyle = useMemo(() => [stylesheet.streamItemWrapper, stylesheet.turnFooterRow], []);
return <View style={rowStyle}>{children}</View>;
return (
<View style={rowStyle} testID="turn-footer">
{children}
</View>
);
}
const stylesheet = StyleSheet.create((theme) => ({
@@ -218,18 +226,22 @@ const stylesheet = StyleSheet.create((theme) => ({
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
// The message above drops its bottom padding for the footer (compactBottom), so this margin
// alone sets the gap. 20 puts the footer as far below the text as the text sits below a
// tool row: 12 message padding + 4 stream gap + 5 badge padding/border, less the footer's
// 1px taller row.
turnFooterRow: {
marginTop: theme.spacing[2] + 5,
marginTop: theme.spacing[4] + theme.spacing[1],
},
turnFooterSlot: {
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
minHeight: 24,
height: TURN_FOOTER_CONTENT_HEIGHT + TURN_FOOTER_BOTTOM_SPACING,
paddingBottom: TURN_FOOTER_BOTTOM_SPACING,
},
turnFooterContent: {
height: 24,
height: TURN_FOOTER_CONTENT_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
@@ -21,7 +21,7 @@ describe("useStreamHistoryWindow", () => {
]).flat();
const { result } = renderHook(() =>
useStreamHistoryWindow({ agentId: "agent-1", items, loadRemoteOlder: vi.fn() }),
useStreamHistoryWindow({ items, loadRemoteOlder: vi.fn() }),
);
expect(result.current.start).toBe(2);
@@ -35,7 +35,6 @@ describe("useStreamHistoryWindow", () => {
const loadRemoteOlder = vi.fn(async () => true);
const { result } = renderHook(() =>
useStreamHistoryWindow({
agentId: "agent-1",
items: [
message("u1", "user_message"),
message("a1", "assistant_message"),
@@ -67,8 +66,7 @@ describe("useStreamHistoryWindow", () => {
message("a2", "assistant_message"),
];
const { result, rerender } = renderHook(
({ history }) =>
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
{ initialProps: { history: items } },
);
@@ -91,8 +89,7 @@ describe("useStreamHistoryWindow", () => {
message("a2", "assistant_message"),
];
const { result, rerender } = renderHook(
({ history }) =>
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
{ initialProps: { history: items } },
);
@@ -108,8 +105,7 @@ describe("useStreamHistoryWindow", () => {
configurable: true,
});
const { result, rerender } = renderHook(
({ history }) =>
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
({ history }) => useStreamHistoryWindow({ items: history, loadRemoteOlder: vi.fn() }),
{
initialProps: {
history: [
@@ -142,7 +138,6 @@ describe("useStreamHistoryWindow", () => {
});
const { result } = renderHook(() =>
useStreamHistoryWindow({
agentId: "agent-1",
items: [
message("u1", "user_message"),
message("a1", "assistant_message"),
@@ -28,22 +28,17 @@ function findEarlierHistoryWindowStart(input: {
}
interface HistoryWindowBoundary {
agentId: string;
boundaryItemId: string | null;
initialized: boolean;
}
function reconcileHistoryWindow(input: {
current: HistoryWindowBoundary;
agentId: string;
boundaryIndex: number;
initialBoundaryItemId: string | null;
hasItems: boolean;
}): HistoryWindowBoundary {
const { current, agentId, boundaryIndex, initialBoundaryItemId, hasItems } = input;
if (current.agentId !== agentId) {
return { agentId, boundaryItemId: initialBoundaryItemId, initialized: hasItems };
}
const { current, boundaryIndex, initialBoundaryItemId, hasItems } = input;
if (!current.initialized && hasItems) {
return { ...current, boundaryItemId: initialBoundaryItemId, initialized: true };
}
@@ -54,28 +49,26 @@ function reconcileHistoryWindow(input: {
}
export function useStreamHistoryWindow(input: {
agentId: string;
items: StreamItem[];
loadRemoteOlder: () => boolean | Promise<boolean>;
}) {
const { agentId, items, loadRemoteOlder } = input;
const { items, loadRemoteOlder } = input;
const initialStart = useMemo(
() => findMountedWindowStart({ items, minMountedCount: getMountedRecentStreamItems() }),
[items],
);
const initialBoundaryItemId = initialStart === 0 ? null : (items[initialStart]?.id ?? null);
const [window, setWindow] = useState(() => ({
agentId,
boundaryItemId: initialBoundaryItemId,
initialized: items.length > 0,
}));
const boundaryItemId = window.agentId === agentId ? window.boundaryItemId : null;
const boundaryItemId = window.boundaryItemId;
const boundaryIndex = useMemo(
() => (boundaryItemId === null ? -1 : items.findIndex((item) => item.id === boundaryItemId)),
[boundaryItemId, items],
);
let start = initialStart;
if (window.agentId === agentId && window.initialized) {
if (window.initialized) {
if (window.boundaryItemId === null) {
start = 0;
} else if (boundaryIndex >= 0) {
@@ -87,13 +80,12 @@ export function useStreamHistoryWindow(input: {
setWindow((current) =>
reconcileHistoryWindow({
current,
agentId,
boundaryIndex,
initialBoundaryItemId,
hasItems: items.length > 0,
}),
);
}, [agentId, boundaryIndex, initialBoundaryItemId, items.length]);
}, [boundaryIndex, initialBoundaryItemId, items.length]);
const revealLoadedHistory = useCallback(
(itemId?: string): boolean => {
@@ -106,13 +98,12 @@ export function useStreamHistoryWindow(input: {
return false;
}
setWindow({
agentId,
boundaryItemId: items[nextStart]?.id ?? null,
initialized: true,
});
return true;
},
[agentId, items, start],
[items, start],
);
const loadOlder = useCallback(async (): Promise<boolean> => {
return revealLoadedHistory() || (await loadRemoteOlder());
+1 -6
View File
@@ -430,10 +430,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
loadOlder: historyPagination.onLoadOlder,
}
: agentHistoryPagination;
useEffect(() => {
setExpandedInlineToolCallIds(new Set());
setExpandedTurnHostIds(new Set());
}, [agentId]);
const isActive = useRetainedPanelActive();
const openInlineFile = useStableEvent(
@@ -476,7 +472,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
useEffect(() => {
if (!isActive) inlinePathOpener.cancel();
return () => inlinePathOpener.cancel();
}, [inlinePathOpener, isActive, agentId]);
}, [inlinePathOpener, isActive]);
const handleInlinePathPress = useStableEvent(
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
if (isActive) void inlinePathOpener.open(target, disposition);
@@ -551,7 +547,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
revealLoadedHistory,
loadOlder,
} = useStreamHistoryWindow({
agentId,
items: presentation.tail,
loadRemoteOlder,
});
+2
View File
@@ -55,6 +55,7 @@ import { DesktopWindowControls } from "@/components/desktop/window-controls";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
import { WorkspaceRenameHost } from "@/components/workspace-rename-host";
import { WorkspaceArchiveUndoShortcutHost } from "@/workspace/archive-undo";
import { WorkspaceExplorerOpenGesture } from "@/mobile-panels/explorer-open-gesture";
import { ProviderSettingsHost } from "@/components/provider-settings-host";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
@@ -557,6 +558,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<PluginCommandCenterActions />
<WorkspacePinShortcutHandler />
<WorkspaceRenameHost />
<WorkspaceArchiveUndoShortcutHost />
<CommandCenter />
<AddProjectFlowHost />
<HostChooserModal />
@@ -0,0 +1,97 @@
import { expect, test } from "vitest";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { createHostBackgroundWorkModel } from "./host";
test("retries a failed observation and receives subsequent updates", async () => {
interface Observer {
snapshot(): void;
update(message: SessionOutboundMessage): void;
error(error: unknown): void;
}
const observers: Observer[] = [];
let releases = 0;
const item = {
id: "shell",
kind: "shell",
description: "sleep",
startedAt: "2026-01-01T00:00:00Z",
};
const client = {
getLastServerInfoMessage: () => ({ features: { agentBackgroundWork: true } }),
stopAgentBackgroundWork: async () => {},
listAgentBackgroundWork: async () => [item],
observeAgentBackgroundWork: () => ({
subscribe: (observer: Observer) => {
observers.push(observer);
},
release: async () => {
releases++;
},
}),
};
const model = createHostBackgroundWorkModel("host", "agent", {
getClient: () => client,
subscribe: () => () => {},
});
const unsubscribe = model.subscribe(() => {});
observers[0].snapshot();
await Promise.resolve();
await Promise.resolve();
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
observers[0].error(new Error("subscription failed"));
expect(model.getSnapshot()).toEqual({ status: "error", items: [item] });
model.retry();
expect(observers).toHaveLength(2);
expect(releases).toBe(1);
observers[1].snapshot();
await Promise.resolve();
await Promise.resolve();
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
observers[0].error(new Error("late error"));
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
observers[1].update({
type: "agent.background_work.update",
payload: { agentId: "agent", items: [] },
});
expect(model.getSnapshot()).toEqual({ status: "ready", items: [] });
unsubscribe();
});
test("starts observing after the same client reconnects", async () => {
let connected = false;
let notify = () => {};
let snapshot = () => {};
const item = {
id: "shell",
kind: "shell",
description: "sleep",
startedAt: "2026-01-01T00:00:00Z",
};
const client = {
getLastServerInfoMessage: () =>
connected ? { features: { agentBackgroundWork: true } } : null,
stopAgentBackgroundWork: async () => {},
listAgentBackgroundWork: async () => [item],
observeAgentBackgroundWork: () => ({
subscribe: (observer: { snapshot(): void }) => {
snapshot = observer.snapshot;
},
release: async () => {},
}),
};
const model = createHostBackgroundWorkModel("host", "agent", {
getClient: () => client,
subscribe: (listener) => {
notify = listener;
return () => {};
},
});
const unsubscribe = model.subscribe(() => {});
connected = true;
notify();
snapshot();
await Promise.resolve();
await Promise.resolve();
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
unsubscribe();
});
+115
View File
@@ -0,0 +1,115 @@
import type { PaseoAgentBackgroundWorkItem } from "@getpaseo/client";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { createBackgroundWorkModel } from "./model";
interface BackgroundWorkClient {
getLastServerInfoMessage(): {
features?: { agentBackgroundWork?: boolean; agentBackgroundWorkStop?: boolean } | null;
} | null;
stopAgentBackgroundWork(agentId: string, item: PaseoAgentBackgroundWorkItem): Promise<void>;
listAgentBackgroundWork(agentId: string): Promise<PaseoAgentBackgroundWorkItem[]>;
observeAgentBackgroundWork(): {
subscribe(observer: {
snapshot(): void;
update(message: SessionOutboundMessage): void;
error(error: unknown): void;
}): unknown;
release(): Promise<void>;
};
}
interface BackgroundWorkHost {
getClient(): BackgroundWorkClient | null;
subscribe(listener: () => void): () => void;
}
function hostFor(serverId: string): BackgroundWorkHost {
const host = getHostRuntimeStore();
return {
getClient: () => host.getSnapshot(serverId)?.client ?? null,
subscribe: (listener) => host.subscribe(serverId, listener),
};
}
export function createHostBackgroundWorkModel(
serverId: string,
agentId: string,
host: BackgroundWorkHost = hostFor(serverId),
) {
let client: BackgroundWorkClient | null = null;
const controls = (items: PaseoAgentBackgroundWorkItem[]) => {
// COMPAT(agentBackgroundWorkStop): added after v0.15.0, remove after 2027-05-03.
if (client?.getLastServerInfoMessage()?.features?.agentBackgroundWorkStop === true)
return items;
return items.map(({ canStop: _canStop, stopToken: _stopToken, ...item }) => item);
};
return createBackgroundWorkModel({
stop: (item) => {
if (!client) return Promise.reject(new Error("Host disconnected"));
return client.stopAgentBackgroundWork(agentId, item);
},
list: () => {
if (!client) return Promise.reject(new Error("Host disconnected"));
return client.listAgentBackgroundWork(agentId).then(controls);
},
observe: ({ snapshot, update, disconnected, failed }) => {
let release: (() => void) | undefined;
let currentObservation:
| ReturnType<BackgroundWorkClient["observeAgentBackgroundWork"]>
| undefined;
const sync = () => {
const candidate = host.getClient();
// The runtime keeps the client across reconnects, but clears server info until handshake.
const next = candidate?.getLastServerInfoMessage() ? candidate : null;
if (next === client) return;
const previousRelease = release;
client = next;
release = undefined;
disconnected();
previousRelease?.();
if (!client) return;
// COMPAT(agentBackgroundWork): added after v0.11.0-beta.5, remove gate after 2027-04-06.
if (client.getLastServerInfoMessage()?.features?.agentBackgroundWork !== true) {
failed();
return;
}
const observedClient = client;
const observation = client.observeAgentBackgroundWork();
currentObservation = observation;
observation.subscribe({
snapshot: () => {
if (client === observedClient && currentObservation === observation) snapshot();
},
update: (message) => {
if (
client === observedClient &&
currentObservation === observation &&
message.type === "agent.background_work.update" &&
message.payload.agentId === agentId
)
update(controls(message.payload.items));
},
error: () => {
if (client === observedClient && currentObservation === observation) failed();
},
});
release = () => {
if (currentObservation === observation) currentObservation = undefined;
void observation
.release()
.catch((error: unknown) =>
console.error("Background work subscription cleanup failed", error),
);
};
};
const unsubscribe = host.subscribe(sync);
sync();
return () => {
unsubscribe();
client = null;
release?.();
};
},
});
}
+155
View File
@@ -0,0 +1,155 @@
import { useCallback, useSyncExternalStore } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks";
import { LiveElapsed } from "@/components/message";
import { Button } from "@/components/ui/button";
import type { PaseoAgentBackgroundWorkItem } from "@getpaseo/client";
import { createHostBackgroundWorkModel } from "./host";
import { backgroundWorkText } from "./presentation";
export { createHostBackgroundWorkModel } from "./host";
function kindKey(kind: string): "shell" | "monitor" | "other" {
if (kind === "shell" || kind === "monitor") return kind;
return "other";
}
export function BackgroundWorkTrack({
model,
count,
}: {
model: ReturnType<typeof createHostBackgroundWorkModel>;
count: number;
}) {
const { t } = useTranslation();
const { items, status, stops } = useSyncExternalStore(
model.subscribe,
model.getSnapshot,
model.getSnapshot,
);
const missing = Object.values(stops ?? {}).filter(
(entry) => !items?.some((item) => item.stopToken === entry.item.stopToken),
);
const rows = [...(items ?? []), ...missing.map((entry) => entry.item)];
if (count === 0 && rows.length === 0) return null;
const visibleCount = items?.length || count;
return (
<ComposerTrackPill
testID="background-work"
panelTitle={t("message.backgroundWork.title")}
segments={[
{
bucket: "running",
text:
visibleCount === 1
? t("message.backgroundWork.pillOne")
: t("message.backgroundWork.pillMany", { count: visibleCount }),
},
]}
>
{status === "loading" || status === "stale" ? (
<ComposerTrackRow>
<Text style={styles.facts}>{t("message.backgroundWork.waiting")}</Text>
</ComposerTrackRow>
) : null}
{status === "error" ? (
<>
<ComposerTrackRow>
<Text style={styles.facts}>{t("message.backgroundWork.error")}</Text>
</ComposerTrackRow>
<ComposerTrackRow
onPress={model.retry}
closeOnSelect={false}
accessibilityLabel={t("message.backgroundWork.retry")}
>
<Text style={styles.title}>{t("message.backgroundWork.retry")}</Text>
</ComposerTrackRow>
</>
) : null}
{rows.map((item) => (
<BackgroundWorkRow
key={item.stopToken ?? item.id}
item={item}
model={model}
live={Boolean(items?.some((entry) => entry.stopToken === item.stopToken))}
ready={status === "ready"}
stopStatus={stops?.[item.stopToken ?? ""]?.status}
/>
))}
</ComposerTrackPill>
);
}
function BackgroundWorkRow({
item,
model,
live,
ready,
stopStatus,
}: {
item: PaseoAgentBackgroundWorkItem;
model: ReturnType<typeof createHostBackgroundWorkModel>;
live: boolean;
ready: boolean;
stopStatus?: "pending" | "error";
}) {
const { t } = useTranslation();
const kind = t(`message.backgroundWork.${kindKey(item.kind)}`);
const stop = useCallback(() => {
void model.stopItem(item);
}, [model, item]);
const dismiss = useCallback(
() => model.dismissStop(item.stopToken ?? ""),
[model, item.stopToken],
);
let detail = t("message.backgroundWork.notRunning");
if (stopStatus === "pending") detail = t("message.backgroundWork.stopping");
const { title, command } = backgroundWorkText(item, kind);
const action = item.canStop && ready && live;
const label =
stopStatus === "pending"
? t("message.backgroundWork.stopping")
: t("message.backgroundWork.stop");
return (
<ComposerTrackRow>
<View style={styles.row}>
<Text style={styles.title}>{title}</Text>
{command ? <Text style={styles.facts}>{command}</Text> : null}
<Text style={styles.facts}>
{kind} ·{" "}
{live ? (
<LiveElapsed startedAt={new Date(item.startedAt)} active style={styles.facts} />
) : (
detail
)}
</Text>
{stopStatus === "error" ? (
<Text style={styles.facts}>{t("message.backgroundWork.stopError")}</Text>
) : null}
</View>
{action ? (
<Button
variant="ghost"
size="sm"
accessibilityLabel={`${t("message.backgroundWork.stop")} ${title}`}
disabled={stopStatus === "pending"}
onPress={stop}
>
{label}
</Button>
) : null}
{!live && stopStatus === "error" ? (
<Button variant="ghost" size="sm" onPress={dismiss}>
{t("message.backgroundWork.dismiss")}
</Button>
) : null}
</ComposerTrackRow>
);
}
const styles = StyleSheet.create((theme) => ({
row: { flexGrow: 1, flexShrink: 1, flexBasis: "auto", minWidth: 0 },
title: { fontSize: theme.fontSize.base, color: theme.colors.foreground },
facts: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted },
}));
@@ -0,0 +1,233 @@
import { expect, test } from "vitest";
import { createBackgroundWorkModel } from "./model";
test("keeps a newer update when an earlier list resolves after it", async () => {
let resolve!: (
value: Array<{ id: string; kind: string; description: string | null; startedAt: string }>,
) => void;
let update!: (
items: Array<{ id: string; kind: string; description: string | null; startedAt: string }>,
) => void;
let snapshot!: () => void;
const model = createBackgroundWorkModel({
list: () =>
new Promise((done) => {
resolve = done;
}),
observe: (callbacks) => {
snapshot = callbacks.snapshot;
update = callbacks.update;
return () => {};
},
});
const unsubscribe = model.subscribe(() => {});
expect(model.getSnapshot().items).toBeNull();
snapshot();
const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" };
update([item]);
resolve([]);
await Promise.resolve();
expect(model.getSnapshot().items).toEqual([item]);
unsubscribe();
});
test("retains the last inventory while reconnecting and replaces it after resync", async () => {
const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" };
const reads: Array<(value: (typeof item)[]) => void> = [];
let snapshot!: () => void;
const model = createBackgroundWorkModel({
list: () => new Promise((resolve) => reads.push(resolve)),
observe: (callbacks) => {
snapshot = callbacks.snapshot;
return () => {};
},
});
const unsubscribe = model.subscribe(() => {});
snapshot();
reads[0]([item]);
await Promise.resolve();
snapshot();
expect(model.getSnapshot().items).toEqual([item]);
reads[1]([]);
await Promise.resolve();
expect(model.getSnapshot().items).toEqual([]);
unsubscribe();
});
test("ignores a list response from a connection that was replaced", async () => {
let snapshot!: () => void;
let disconnected!: () => void;
let resolve!: (
items: Array<{ id: string; kind: string; description: string | null; startedAt: string }>,
) => void;
const model = createBackgroundWorkModel({
list: () =>
new Promise((done) => {
resolve = done;
}),
observe: (callbacks) => {
snapshot = callbacks.snapshot;
disconnected = callbacks.disconnected;
return () => {};
},
});
const unsubscribe = model.subscribe(() => {});
snapshot();
disconnected();
resolve([{ id: "old", kind: "shell", description: null, startedAt: "2026-01-01T00:00:00Z" }]);
await Promise.resolve();
expect(model.getSnapshot().items).toBeNull();
unsubscribe();
});
test("retains stale items on failure and retries the inventory", async () => {
const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" };
let snapshot!: () => void;
let resolve!: (items: (typeof item)[]) => void;
let reject!: (error: Error) => void;
const model = createBackgroundWorkModel({
list: () =>
new Promise((done, fail) => {
resolve = done;
reject = fail;
}),
observe: (callbacks) => {
snapshot = callbacks.snapshot;
return () => {};
},
});
const unsubscribe = model.subscribe(() => {});
snapshot();
resolve([item]);
await Promise.resolve();
snapshot();
expect(model.getSnapshot()).toEqual({ status: "stale", items: [item] });
reject(new Error("offline"));
await Promise.resolve();
expect(model.getSnapshot()).toEqual({ status: "error", items: [item] });
model.retry();
expect(model.getSnapshot()).toEqual({ status: "stale", items: [item] });
resolve([]);
await Promise.resolve();
expect(model.getSnapshot()).toEqual({ status: "ready", items: [] });
unsubscribe();
});
test("reports an initial read failure without claiming an empty inventory", async () => {
let snapshot!: () => void;
const model = createBackgroundWorkModel({
list: async () => {
throw new Error("offline");
},
observe: (callbacks) => {
snapshot = callbacks.snapshot;
return () => {};
},
});
const unsubscribe = model.subscribe(() => {});
snapshot();
await Promise.resolve();
expect(model.getSnapshot()).toEqual({ status: "error", items: null });
unsubscribe();
});
test("recreates a failed observation on retry and resumes live updates", async () => {
const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" };
let observation!: Parameters<Parameters<typeof createBackgroundWorkModel>[0]["observe"]>[0];
let starts = 0;
let releases = 0;
const model = createBackgroundWorkModel({
list: async () => [item],
observe: (callbacks) => {
observation = callbacks;
starts++;
return () => {
releases++;
};
},
});
const unsubscribe = model.subscribe(() => {});
observation.failed();
expect(model.getSnapshot()).toEqual({ status: "error", items: null });
model.retry();
expect(starts).toBe(2);
expect(releases).toBe(1);
observation.snapshot();
await Promise.resolve();
expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] });
observation.update([]);
expect(model.getSnapshot()).toEqual({ status: "ready", items: [] });
unsubscribe();
});
test("keeps a stop pending across a roster removal and exposes a failed outcome", async () => {
const item = {
id: "1",
kind: "shell",
description: "sleep",
startedAt: "2026-01-01",
canStop: true,
stopToken: "generation-1",
};
let update!: (items: (typeof item)[]) => void;
let reject!: (error: Error) => void;
const model = createBackgroundWorkModel({
list: async () => [item],
stop: () =>
new Promise((_, fail) => {
reject = fail;
}),
observe: (callbacks) => {
update = callbacks.update;
return () => {};
},
});
const unsubscribe = model.subscribe(() => {});
update([item]);
const pending = model.stopItem(item);
update([]);
expect(model.getSnapshot().stops?.["generation-1"]).toEqual({ status: "pending", item });
reject(new Error("connection lost"));
await pending;
expect(model.getSnapshot().stops?.["generation-1"]).toEqual({ status: "error", item });
model.dismissStop("generation-1");
expect(model.getSnapshot().stops).toEqual({});
unsubscribe();
});
test("ignores a stop response from a disconnected generation", async () => {
const first = {
id: "1",
kind: "shell",
description: "old",
startedAt: "2026-01-01",
canStop: true,
stopToken: "first",
};
const replacement = { ...first, description: "new", stopToken: "second" };
let update!: (items: (typeof first)[]) => void;
let disconnect!: () => void;
let resolve!: () => void;
const model = createBackgroundWorkModel({
list: async () => [],
stop: () =>
new Promise((done) => {
resolve = done;
}),
observe: (callbacks) => {
update = callbacks.update;
disconnect = callbacks.disconnected;
return () => {};
},
});
const unsubscribe = model.subscribe(() => {});
update([first]);
const pending = model.stopItem(first);
disconnect();
update([replacement]);
resolve();
await pending;
expect(model.getSnapshot().items).toEqual([replacement]);
expect(model.getSnapshot().stops?.first).toEqual({ status: "error", item: first });
unsubscribe();
});
+121
View File
@@ -0,0 +1,121 @@
import type { PaseoAgentBackgroundWorkItem as AgentBackgroundWorkItem } from "@getpaseo/client";
interface Port {
list(): Promise<AgentBackgroundWorkItem[]>;
stop?(item: AgentBackgroundWorkItem): Promise<void>;
observe(callbacks: {
snapshot(): void;
disconnected(): void;
failed(): void;
update(items: AgentBackgroundWorkItem[]): void;
}): () => void;
}
interface BackgroundWorkState {
status: "loading" | "stale" | "ready" | "error";
items: AgentBackgroundWorkItem[] | null;
stops?: Record<string, { status: "pending" | "error"; item: AgentBackgroundWorkItem }>;
}
export function createBackgroundWorkModel(port: Port) {
let state: BackgroundWorkState = { status: "loading", items: null };
let sequence = 0;
let stopGeneration = 0;
let stop: (() => void) | undefined;
let observationFailed = false;
const listeners = new Set<() => void>();
const publish = (next: BackgroundWorkState) => {
state =
next.stops === undefined && state.stops === undefined
? next
: { ...next, stops: next.stops ?? state.stops };
for (const listener of listeners) listener();
};
const refresh = async () => {
const request = ++sequence;
publish({ status: state.items === null ? "loading" : "stale", items: state.items });
try {
const items = await port.list();
if (request === sequence) publish({ status: "ready", items });
} catch {
if (request === sequence) publish({ status: "error", items: state.items });
}
};
const start = () => {
observationFailed = false;
stop = port.observe({
snapshot: () => {
void refresh();
},
disconnected: () => {
sequence++;
stopGeneration++;
const stops =
state.stops &&
Object.fromEntries(
Object.entries(state.stops).map(([id, entry]) => [
id,
{ ...entry, status: "error" as const },
]),
);
publish({ status: state.items === null ? "loading" : "stale", items: state.items, stops });
},
failed: () => {
observationFailed = true;
sequence++;
publish({ status: "error", items: state.items });
},
update: (items) => {
sequence++;
publish({ status: "ready", items });
},
});
};
return {
getSnapshot: () => state,
dismissStop: (token: string) => {
const stops = { ...state.stops };
delete stops[token];
publish({ ...state, stops });
},
stopItem: async (item: AgentBackgroundWorkItem) => {
const token = item.stopToken;
if (!port.stop || !token || state.stops?.[token]?.status === "pending") return;
if (!state.items?.some((entry) => entry.stopToken === token)) return;
const generation = stopGeneration;
publish({ ...state, stops: { ...state.stops, [token]: { status: "pending", item } } });
try {
await port.stop(item);
if (generation !== stopGeneration) return;
const stops = { ...state.stops };
delete stops[token];
publish({ ...state, stops });
} catch {
if (generation === stopGeneration)
publish({ ...state, stops: { ...state.stops, [token]: { status: "error", item } } });
}
},
retry: () => {
if (observationFailed) {
stop?.();
stop = undefined;
publish({ status: state.items === null ? "loading" : "stale", items: state.items });
start();
} else {
void refresh();
}
},
subscribe(listener: () => void) {
listeners.add(listener);
if (!stop) start();
return () => {
listeners.delete(listener);
if (!listeners.size) {
sequence++;
stop?.();
stop = undefined;
}
};
},
};
}
@@ -0,0 +1,20 @@
import { expect, test } from "vitest";
import { backgroundWorkText } from "./presentation";
test("shows descriptions and commands once, with useful fallbacks", () => {
expect(
backgroundWorkText({ description: "Watch build", command: "tail -f build.log" }, "Monitor"),
).toEqual({ title: "Watch build", command: "tail -f build.log" });
expect(backgroundWorkText({ description: null, command: "sleep 10" }, "Shell")).toEqual({
title: "sleep 10",
command: null,
});
expect(backgroundWorkText({ description: "sleep 10", command: "sleep 10" }, "Shell")).toEqual({
title: "sleep 10",
command: null,
});
expect(backgroundWorkText({ description: null }, "Shell")).toEqual({
title: "Shell",
command: null,
});
});
@@ -0,0 +1,7 @@
export function backgroundWorkText(
item: { description: string | null; command?: string },
kind: string,
): { title: string; command: string | null } {
const title = item.description ?? item.command ?? kind;
return { title, command: item.command && item.command !== title ? item.command : null };
}
@@ -41,7 +41,15 @@ export function BranchSwitcher({
const toast = useToast();
const queryClient = useQueryClient();
const { branchOptions, isOpen, setIsOpen, handleBranchSelect } = useBranchSwitcher({
const {
branchOptions,
isLoadingBranches,
isFetchingBranches,
branchError,
isOpen,
setIsOpen,
handleBranchSelect,
} = useBranchSwitcher({
client,
normalizedServerId: serverId,
normalizedWorkspaceId: workspaceId,
@@ -73,6 +81,22 @@ export function BranchSwitcher({
[branchLeadingSlot],
);
let emptyText = t("branchSwitcher.empty");
if (isLoadingBranches) emptyText = t("common.states.loading");
else if (branchError) emptyText = "";
const errorFooter = useMemo(
() =>
branchError ? (
<View style={styles.footer}>
<Text accessibilityRole="alert" style={styles.error}>
{isFetchingBranches ? t("common.states.loading") : t("branchSwitcher.failedToLoad")}
</Text>
</View>
) : undefined,
[branchError, isFetchingBranches, t],
);
if (!currentBranchName) {
return null;
}
@@ -85,6 +109,7 @@ export function BranchSwitcher({
testID={testID}
label={currentBranchName}
open={isOpen}
disabled={!isConnected}
onPress={handleOpen}
accessibilityRole="button"
accessibilityLabel={t("branchSwitcher.currentBranch", {
@@ -103,7 +128,8 @@ export function BranchSwitcher({
searchable
placeholder={t("branchSwitcher.placeholder")}
searchPlaceholder={t("branchSwitcher.searchPlaceholder")}
emptyText={t("branchSwitcher.empty")}
emptyText={emptyText}
footer={errorFooter}
title={t("branchSwitcher.title")}
open={isOpen}
onOpenChange={setIsOpen}
@@ -122,6 +148,8 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 1,
minWidth: 0,
},
footer: { padding: theme.spacing[3], gap: theme.spacing[2] },
error: { color: theme.colors.destructive, fontSize: theme.fontSize.sm },
tooltipText: {
color: theme.colors.popoverForeground,
fontSize: theme.fontSize.sm,
@@ -13,7 +13,11 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import {
TOOLBAR_GLYPH_SIZES,
toolbarContentSlot,
type ToolbarGlyph,
} from "@/components/ui/control-geometry";
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
@@ -61,7 +65,7 @@ interface CombinedModelSelectorProps {
*/
triggerFill?: boolean;
toolbar?: {
glyphSize: number;
glyph: ToolbarGlyph;
showCaret: boolean;
};
}
@@ -253,11 +257,13 @@ export function CombinedModelSelector({
chevron={toolbar?.showCaret === false ? null : undefined}
>
{selectedProvider.trim().length > 0 ? (
<View style={toolbar?.glyphSize === 20 ? styles.toolbarGlyph20 : styles.toolbarGlyph16}>
<View style={toolbar ? styles.toolbarGlyph : styles.glyph}>
<ModelProviderGlyph
provider={selectedProvider}
serverId={serverId}
size={toolbar?.glyphSize ?? ICON_SIZE.md}
size={toolbar?.glyph.size ?? ICON_SIZE.md}
strokeWidth={toolbar?.glyph.strokeWidth}
tone={toolbar?.glyph.tone}
/>
</View>
) : null}
@@ -293,31 +299,30 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: 0,
},
trigger: {
height: TOOLBAR_CONTROL_HEIGHT,
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
...toolbarContentSlot(theme.spacing[2]),
borderRadius: theme.borderRadius.full,
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
},
toolbarGlyph16: {
width: 16,
height: 16,
glyph: {
width: ICON_SIZE.md,
height: ICON_SIZE.md,
flexShrink: 0,
},
toolbarGlyph20: {
width: 20,
height: 20,
toolbarGlyph: {
width: TOOLBAR_GLYPH_SIZES.label,
height: TOOLBAR_GLYPH_SIZES.label,
flexShrink: 0,
},
triggerPressed: {
backgroundColor: theme.colors.surface0,
backgroundColor: theme.colors.surface2,
},
triggerDisabled: {
opacity: 0.5,
@@ -329,9 +334,11 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
// A custom trigger, such as a form field, owns its own box: none of the toolbar slot applies.
customTriggerWrapper: {
paddingHorizontal: 0,
paddingVertical: 0,
marginHorizontal: 0,
height: "auto",
},
triggerFill: {
@@ -1,5 +1,11 @@
import { useCallback, useMemo, useState } from "react";
import { Pressable, Text, View, useWindowDimensions } from "react-native";
import {
Pressable,
type PressableStateCallbackType,
Text,
View,
useWindowDimensions,
} from "react-native";
import Svg, { Circle } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
@@ -9,7 +15,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { useHostReportsUsage } from "@/usage";
import type { Theme } from "@/styles/theme";
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { TOOLBAR_DISC_SIZE, type ToolbarRing } from "@/components/ui/control-geometry";
import { ContextWindowDetails } from "./context-window-details";
import { ContextWindowSheet } from "./context-window-sheet";
import { formatContextPercentage } from "./context-window-meter.utils";
@@ -21,8 +27,8 @@ interface ContextWindowMeterProps {
usedTokens: number | null;
totalCostUsd?: number | null;
showPercentage?: boolean;
/** Optional glyph envelope for icon-toolbar alignment. */
glyphSize?: number;
/** The ring's box and stroke when it sits in an icon toolbar. */
toolbarRing?: ToolbarRing;
}
const SVG_SIZE = 14;
@@ -71,7 +77,7 @@ function getProgressColor(percentage: number, theme: Theme): string {
return theme.colors.foregroundMuted;
}
function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
function getMeterGeometry(showPercentage: boolean, ring?: ToolbarRing) {
if (showPercentage) {
return {
svgSize: COMPACT_SVG_SIZE,
@@ -80,8 +86,8 @@ function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
containerStyle: styles.containerWithLabel,
};
}
const resolvedSize = glyphSize ?? SVG_SIZE;
const resolvedStrokeWidth = glyphSize ? 2 : STROKE_WIDTH;
const resolvedSize = ring?.size ?? SVG_SIZE;
const resolvedStrokeWidth = ring?.strokeWidth ?? STROKE_WIDTH;
return {
svgSize: resolvedSize,
radius: (resolvedSize - resolvedStrokeWidth) / 2,
@@ -151,7 +157,7 @@ export function ContextWindowMeter({
usedTokens,
totalCostUsd,
showPercentage = false,
glyphSize,
toolbarRing,
}: ContextWindowMeterProps) {
const { t, i18n } = useTranslation();
const { width } = useWindowDimensions();
@@ -165,7 +171,7 @@ export function ContextWindowMeter({
const closeSheet = useCallback(() => setIsSheetOpen(false), []);
const percentage =
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
const geometry = getMeterGeometry(showPercentage, glyphSize);
const geometry = getMeterGeometry(showPercentage, toolbarRing);
const context = useMemo(
() =>
@@ -184,6 +190,10 @@ export function ContextWindowMeter({
const formattedSessionCost =
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
const containerStyle = geometry.containerStyle;
const pressableStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [containerStyle, pressed && styles.pressed],
[containerStyle],
);
const ring = (
<ContextWindowRing
size={geometry.svgSize}
@@ -207,7 +217,7 @@ export function ContextWindowMeter({
return (
<>
<Pressable
style={containerStyle}
style={pressableStyle}
testID="context-window-meter"
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
@@ -241,7 +251,7 @@ export function ContextWindowMeter({
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
style={containerStyle}
style={pressableStyle}
testID="context-window-meter"
accessibilityRole="image"
accessibilityLabel={accessibilityLabel}
@@ -307,20 +317,23 @@ export function ContextWindowMeter({
const styles = StyleSheet.create((theme) => ({
container: {
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
containerWithLabel: {
height: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_DISC_SIZE,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
borderRadius: theme.borderRadius.full,
},
pressed: {
backgroundColor: theme.colors.surface2,
},
percentageLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
+79 -24
View File
@@ -17,6 +17,7 @@ import {
Text,
useWindowDimensions,
View,
type LayoutChangeEvent,
} from "react-native";
import { Gesture, ScrollView as GHScrollView } from "react-native-gesture-handler";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
@@ -40,6 +41,17 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
import { SidebarFooterIconHighlight } from "@/components/sidebar/sidebar-footer-icon-highlight";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
import { SidebarFooterOverflowMenu } from "@/components/sidebar/sidebar-footer-overflow-menu";
import {
resolveSidebarFooterLayout,
SIDEBAR_FOOTER_ACTIONS,
type SidebarFooterAction,
} from "@/components/sidebar-footer-layout";
import {
controlSizingForLayout,
mapLayout,
TOOLBAR_CONTROL_SIZES,
} from "@/components/ui/control-geometry";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { DensityIcon } from "@/components/ui/density-icon";
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
@@ -55,6 +67,7 @@ import {
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { density } from "@/styles/density";
import { SPACING } from "@/styles/theme";
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import { RetainedPanelActivity } from "@/components/retained-panel";
@@ -79,6 +92,9 @@ import { SidebarWorkspaceList } from "./sidebar-workspace-list";
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
/** The footer icon line's inset; the overflow layout counts it as space the buttons can't use. */
const FOOTER_ROW_PADDING = SPACING[2];
const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null;
interface SidebarSharedProps {
@@ -410,35 +426,72 @@ function SidebarFooter({
}) {
const newAgentKeys = useShortcutKeys("new-agent");
const settingsKeys = useShortcutKeys("toggle-settings");
const isCompactLayout = useIsCompactFormFactor();
const [rowWidth, setRowWidth] = useState(0);
const handleRowLayout = useCallback((event: LayoutChangeEvent) => {
setRowWidth(event.nativeEvent.layout.width);
}, []);
const { inline, overflow } = useMemo(
() =>
resolveSidebarFooterLayout({
rowWidth,
controlSize: TOOLBAR_CONTROL_SIZES[controlSizingForLayout(isCompactLayout)],
rowPadding: FOOTER_ROW_PADDING,
actions: SIDEBAR_FOOTER_ACTIONS,
}),
[rowWidth, isCompactLayout],
);
const isInline = (action: SidebarFooterAction) => inline.includes(action);
// One line of icons: Add project, Usage, Hosts, then Help and Settings at the end.
// One line of icons: Add project, Usage, Hosts, then Help and Settings at the end. Actions that
// don't fit move behind a caret before Settings.
return (
<UsageSidebarRoot>
<View style={styles.footerContainer} testID="sidebar-footer">
<SidebarFooterRows onBeforeNavigate={onBeforeNavigate} />
<View style={styles.sidebarFooter} testID="sidebar-footer-bottom-line">
<FooterIconButton
onPress={handleOpenProject}
testID="sidebar-add-project"
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
/>
<SidebarUsageIcon label={labels.usage} />
<SidebarHostPicker
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
<View
style={styles.sidebarFooter}
onLayout={handleRowLayout}
testID="sidebar-footer-bottom-line"
>
{isInline("add-project") ? (
<FooterIconButton
onPress={handleOpenProject}
testID="sidebar-add-project"
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
/>
) : null}
{isInline("usage") ? <SidebarUsageIcon label={labels.usage} /> : null}
{isInline("hosts") ? (
<SidebarHostPicker
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
) : null}
<View style={styles.footerSpacer} />
<SidebarHelpMenu />
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
/>
{isInline("help") ? <SidebarHelpMenu /> : null}
{overflow.length > 0 ? (
<SidebarFooterOverflowMenu
actions={overflow}
labels={labels}
onAddProject={handleOpenProject}
onSettings={handleSettings}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
/>
) : null}
{isInline("settings") ? (
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
/>
) : null}
</View>
</View>
</UsageSidebarRoot>
@@ -937,10 +990,12 @@ const styles = StyleSheet.create((theme) => ({
borderTopColor: theme.colors.border,
},
// Buttons sit edge to edge; their own inset spaces the glyphs.
// As tall as its buttons from the first frame, which places none until the row has measured.
sidebarFooter: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: theme.spacing[2],
minHeight: mapLayout(density.iconButton, (height) => height + theme.spacing[1.5] * 2),
paddingHorizontal: FOOTER_ROW_PADDING,
paddingVertical: theme.spacing[1.5],
},
// Pushes Help and Settings to the end of the icon line.
@@ -216,17 +216,19 @@ export function ModelProviderGlyph({
provider,
serverId,
size,
strokeWidth,
tone = "muted",
}: {
provider: string;
serverId: string | null;
size: number;
strokeWidth?: number;
tone?: ProviderGlyphTone;
}) {
const Icon = useProviderIcon(provider, serverId);
const color =
tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color;
return <Icon size={size} color={color} />;
return <Icon size={size} strokeWidth={strokeWidth} color={color} />;
}
function HeaderSettingsIcon({ disabled }: { disabled: boolean }) {
@@ -20,6 +20,8 @@ import { resolveProviderIconName } from "@/components/provider-icon-name";
export interface ProviderIconProps {
size: number;
color: string;
/** Honoured by outline glyphs such as the fallback; brand marks are filled and ignore it. */
strokeWidth?: number;
}
export type ProviderIconComponent = ComponentType<ProviderIconProps>;
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import {
resolveSidebarFooterLayout,
SIDEBAR_FOOTER_ACTIONS,
} from "@/components/sidebar-footer-layout";
import { DEFAULT_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
const TOUCH = 44;
const POINTER = 28;
const ROW_PADDING = 8;
function layout(rowWidth: number, controlSize: number) {
return resolveSidebarFooterLayout({
rowWidth,
controlSize,
rowPadding: ROW_PADDING,
actions: SIDEBAR_FOOTER_ACTIONS,
});
}
describe("sidebar footer layout", () => {
it("keeps every action inline at the default width with either sizing", () => {
for (const controlSize of [TOUCH, POINTER]) {
expect(layout(DEFAULT_SIDEBAR_WIDTH, controlSize)).toEqual({
inline: ["add-project", "usage", "hosts", "help", "settings"],
overflow: [],
});
}
});
it("keeps every action inline at the minimum width with pointer sizing", () => {
expect(layout(MIN_SIDEBAR_WIDTH, POINTER).overflow).toEqual([]);
});
// The iPhone Duo report: the folded sidebar column is about the minimum width, and touch
// sizing makes five 44pt buttons plus padding 236pt wide.
it("moves Help and Usage behind the caret at the minimum width with touch sizing", () => {
expect(layout(MIN_SIDEBAR_WIDTH, TOUCH)).toEqual({
inline: ["add-project", "hosts", "settings"],
overflow: ["usage", "help"],
});
});
it("keeps Settings and Add project inline longest", () => {
// Three slots: two actions and the caret.
expect(layout(3 * TOUCH + 2 * ROW_PADDING, TOUCH)).toEqual({
inline: ["add-project", "settings"],
overflow: ["usage", "hosts", "help"],
});
// Two slots: Settings and the caret.
expect(layout(2 * TOUCH + 2 * ROW_PADDING, TOUCH)).toEqual({
inline: ["settings"],
overflow: ["add-project", "usage", "hosts", "help"],
});
});
it("puts everything behind the caret when only the caret fits", () => {
expect(layout(TOUCH + 2 * ROW_PADDING - 10, TOUCH)).toEqual({
inline: [],
overflow: ["add-project", "usage", "hosts", "help", "settings"],
});
});
it("fits all five actions exactly at their combined width", () => {
const exact = 5 * TOUCH + 2 * ROW_PADDING;
expect(layout(exact, TOUCH).overflow).toEqual([]);
// Browser layout rounds a little below the exact width.
expect(layout(exact - 0.4, TOUCH).overflow).toEqual([]);
expect(layout(exact - 1, TOUCH).overflow).toEqual(["usage", "help"]);
});
it("only places the actions it is given", () => {
expect(
resolveSidebarFooterLayout({
rowWidth: 3 * TOUCH + 2 * ROW_PADDING,
controlSize: TOUCH,
rowPadding: ROW_PADDING,
actions: ["add-project", "hosts", "settings"],
}),
).toEqual({ inline: ["add-project", "hosts", "settings"], overflow: [] });
});
// Laying out against a guessed width would paint a clipped row for a frame on narrow sidebars.
it("places nothing before the row has been measured", () => {
for (const controlSize of [TOUCH, POINTER]) {
expect(layout(0, controlSize)).toEqual({ inline: [], overflow: [] });
}
});
});
@@ -0,0 +1,62 @@
/** The sidebar footer's icon line, in row order. */
export const SIDEBAR_FOOTER_ACTIONS = [
"add-project",
"usage",
"hosts",
"help",
"settings",
] as const;
export type SidebarFooterAction = (typeof SIDEBAR_FOOTER_ACTIONS)[number];
/** The order actions keep their place in a row too narrow for all of them. */
const KEEP_ORDER: readonly SidebarFooterAction[] = [
"settings",
"add-project",
"hosts",
"usage",
"help",
];
/** Measured widths land a hair under the exact sum of their parts. */
const MEASUREMENT_TOLERANCE = 0.5;
export interface SidebarFooterLayout {
/** Actions drawn in the row, in row order. */
inline: SidebarFooterAction[];
/** Actions behind the overflow caret, in row order. Empty when everything fits. */
overflow: SidebarFooterAction[];
}
/**
* Splits the footer's actions between the row and the overflow menu. Buttons keep their full size;
* when they don't all fit, the caret takes one slot and the actions that lose their place move
* behind it, Help first and Settings last. Nothing is placed until the row has a measured width.
*/
export function resolveSidebarFooterLayout(input: {
rowWidth: number;
controlSize: number;
rowPadding: number;
actions: readonly SidebarFooterAction[];
}): SidebarFooterLayout {
const { rowWidth, controlSize, rowPadding, actions } = input;
// An unmeasured row stays empty: any guess would paint a clipped row for a frame on a narrow
// sidebar before the real width arrives.
if (rowWidth <= 0) {
return { inline: [], overflow: [] };
}
const slots = Math.floor(
(rowWidth - rowPadding * 2 + MEASUREMENT_TOLERANCE) / Math.max(controlSize, 1),
);
if (slots >= actions.length) {
return { inline: [...actions], overflow: [] };
}
const inlineCount = Math.max(slots - 1, 0);
const kept = new Set(
KEEP_ORDER.filter((action) => actions.includes(action)).slice(0, inlineCount),
);
return {
inline: actions.filter((action) => kept.has(action)),
overflow: actions.filter((action) => !kept.has(action)),
};
}
@@ -649,6 +649,7 @@ function WorkspaceRowRightGroup({
const {
trailingPresentation,
showKebab: showKebabInSlot,
showArchiveButton,
showScrim,
renderSlot,
reserveSlotWidth,
@@ -675,6 +676,8 @@ function WorkspaceRowRightGroup({
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
workspaceKey={workspace.workspaceKey}
onArchiveButtonPress={showArchiveButton ? onArchive : undefined}
>
{onArchive ? (
<SidebarWorkspaceMenu
@@ -0,0 +1,69 @@
import type { ReactElement } from "react";
import { Text, View } from "react-native";
import type { LucideIcon } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { DensityIcon } from "@/components/ui/density-icon";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { density } from "@/styles/density";
import { SidebarFooterIconHighlight } from "./sidebar-footer-icon-highlight";
/**
* A footer icon that opens a menu. Sized and drawn like the footer's other icon buttons; the
* tooltip stays out of the way while the menu is open.
*/
export function SidebarFooterMenuTrigger({
icon,
label,
menuOpen,
testID,
}: {
icon: LucideIcon;
label: string;
menuOpen: boolean;
testID: string;
}): ReactElement {
return (
<Tooltip delayDuration={300} enabledOnDesktop={!menuOpen}>
<TooltipTrigger asChild>
<View>
<DropdownMenuTrigger
style={styles.trigger}
testID={testID}
accessibilityRole="button"
accessibilityLabel={label}
>
{({ hovered, pressed, open }) => (
<>
<SidebarFooterIconHighlight visible={pressed || open} />
<DensityIcon
icon={icon}
size="md"
color={hovered ? "foreground" : "foregroundMuted"}
/>
</>
)}
</DropdownMenuTrigger>
</View>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{label}</Text>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: density.iconButton,
height: density.iconButton,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
},
}));
@@ -0,0 +1,245 @@
import { useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronUp,
CircleGauge,
CircleHelp,
FolderPlus,
Plus,
Server,
Settings,
} from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
import type { SidebarFooterAction } from "@/components/sidebar-footer-layout";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSubTrigger,
type MenuPageDefinition,
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useHosts } from "@/runtime/host-runtime";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { orderHostsLocalFirst } from "@/types/host-connection";
import { useOpenSidebarUsage } from "@/usage";
import { SidebarFooterMenuTrigger } from "./sidebar-footer-menu-trigger";
import { SidebarHelpMenuItems } from "./sidebar-help-menu";
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedFolderPlus = withUnistyles(FolderPlus);
const ThemedCircleGauge = withUnistyles(CircleGauge);
const ThemedServer = withUnistyles(Server);
const ThemedCircleHelp = withUnistyles(CircleHelp);
const ThemedSettings = withUnistyles(Settings);
const ThemedPlus = withUnistyles(Plus);
const addProjectLeading = <ThemedFolderPlus size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const usageLeading = <ThemedCircleGauge size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const hostsLeading = <ThemedServer size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const helpLeading = <ThemedCircleHelp size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const settingsLeading = <ThemedSettings size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const addHostLeading = <ThemedPlus size={ICON_SIZE.sm} uniProps={mutedIconMapping} />;
const HOSTS_PAGE_ID = "hosts";
const HELP_PAGE_ID = "help";
export interface SidebarFooterOverflowMenuProps {
/** The footer actions that did not fit in the row, in row order. */
actions: readonly SidebarFooterAction[];
labels: { addProject: string; hosts: string; settings: string; usage: string };
onAddProject: () => void;
onSettings: () => void;
onAddHost: () => void;
onOpenHostSettings: (serverId: string) => void;
}
/**
* The caret at the end of a footer row too narrow for every action. It lists the actions that
* lost their place, with the same test IDs they carry in the row; Hosts and Help, menus of their
* own in the row, open as pages of this menu.
*/
export function SidebarFooterOverflowMenu({
actions,
labels,
onAddProject,
onSettings,
onAddHost,
onOpenHostSettings,
}: SidebarFooterOverflowMenuProps): ReactElement {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const openUsage = useOpenSidebarUsage();
const newAgentKeys = useShortcutKeys("new-agent");
const settingsKeys = useShortcutKeys("toggle-settings");
const helpLabel = t("sidebar.help.trigger");
const addProjectTrailing = useMemo(
() => (newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null),
[newAgentKeys],
);
const settingsTrailing = useMemo(
() => (settingsKeys ? <Shortcut chord={settingsKeys} /> : null),
[settingsKeys],
);
const pages = useMemo((): MenuPageDefinition[] => {
const result: MenuPageDefinition[] = [];
if (actions.includes("hosts")) {
result.push({
id: HOSTS_PAGE_ID,
title: labels.hosts,
content: <HostsPage onAddHost={onAddHost} onOpenHostSettings={onOpenHostSettings} />,
});
}
if (actions.includes("help")) {
result.push({ id: HELP_PAGE_ID, title: helpLabel, content: <SidebarHelpMenuItems /> });
}
return result;
}, [actions, helpLabel, labels.hosts, onAddHost, onOpenHostSettings]);
function renderAction(action: SidebarFooterAction): ReactElement {
switch (action) {
case "add-project":
return (
<DropdownMenuItem
key={action}
testID="sidebar-add-project"
leading={addProjectLeading}
trailing={addProjectTrailing}
onSelect={onAddProject}
>
{labels.addProject}
</DropdownMenuItem>
);
case "usage":
return (
<DropdownMenuItem
key={action}
testID="sidebar-usage-icon"
leading={usageLeading}
onSelect={openUsage}
>
{labels.usage}
</DropdownMenuItem>
);
case "hosts":
return (
<DropdownMenuSubTrigger
key={action}
id={HOSTS_PAGE_ID}
testID="sidebar-hosts-trigger"
leading={hostsLeading}
>
{labels.hosts}
</DropdownMenuSubTrigger>
);
case "help":
return (
<DropdownMenuSubTrigger
key={action}
id={HELP_PAGE_ID}
testID="sidebar-help"
leading={helpLeading}
>
{helpLabel}
</DropdownMenuSubTrigger>
);
case "settings":
return (
<DropdownMenuItem
key={action}
testID="sidebar-settings"
leading={settingsLeading}
trailing={settingsTrailing}
onSelect={onSettings}
>
{labels.settings}
</DropdownMenuItem>
);
}
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<SidebarFooterMenuTrigger
icon={ChevronUp}
label={t("sidebar.actions.moreActions")}
menuOpen={open}
testID="sidebar-footer-overflow"
/>
<DropdownMenuContent
side="top"
align="end"
offset={8}
width={240}
pages={pages}
scrollable
testID="sidebar-footer-overflow-menu"
>
{actions.map(renderAction)}
</DropdownMenuContent>
</DropdownMenu>
);
}
/** The host picker's choices as a menu page: each host opens its settings, then Add host. */
function HostsPage({
onAddHost,
onOpenHostSettings,
}: {
onAddHost: () => void;
onOpenHostSettings: (serverId: string) => void;
}): ReactElement {
const { t } = useTranslation();
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const orderedHosts = useMemo(
() => orderHostsLocalFirst(hosts, localServerId),
[hosts, localServerId],
);
return (
<>
{orderedHosts.map((host) => (
<HostRow
key={host.serverId}
serverId={host.serverId}
label={host.label}
onOpenHostSettings={onOpenHostSettings}
/>
))}
{orderedHosts.length > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem testID="sidebar-host-add" leading={addHostLeading} onSelect={onAddHost}>
{t("settings.addHost")}
</DropdownMenuItem>
</>
);
}
function HostRow({
serverId,
label,
onOpenHostSettings,
}: {
serverId: string;
label: string;
onOpenHostSettings: (serverId: string) => void;
}): ReactElement {
const handleSelect = useCallback(
() => onOpenHostSettings(serverId),
[onOpenHostSettings, serverId],
);
const leading = useMemo(() => <HostStatusDotSlot serverId={serverId} />, [serverId]);
return (
<DropdownMenuItem
testID={`sidebar-host-row-${serverId}`}
leading={leading}
onSelect={handleSelect}
>
{label}
</DropdownMenuItem>
);
}
@@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import { Text, View } from "react-native";
import { View } from "react-native";
import { Activity, CircleHelp, Gift, Keyboard } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
@@ -12,17 +12,13 @@ import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DensityIcon } from "@/components/ui/density-icon";
import { SidebarFooterIconHighlight } from "./sidebar-footer-icon-highlight";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SidebarFooterMenuTrigger } from "./sidebar-footer-menu-trigger";
import { useAppDiagnosticStore } from "@/diagnostics/store";
import { useHardwareKeyboardPresent } from "@/keyboard";
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore } from "@/stores/session-store";
import { density } from "@/styles/density";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -77,12 +73,32 @@ function HostVersionHint({ host }: { host: HostProfile }) {
);
}
/** The Help menu's trigger, and the menu it opens. */
export function SidebarHelpMenu() {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<SidebarFooterMenuTrigger
icon={CircleHelp}
label={t("sidebar.help.trigger")}
menuOpen={open}
testID="sidebar-help"
/>
<DropdownMenuContent side="top" align="end" offset={8} width={280} testID="sidebar-help-menu">
<SidebarHelpMenuItems />
</DropdownMenuContent>
</DropdownMenu>
);
}
/** The Help menu's rows, shared by the Help menu and the footer's overflow page for it. */
export function SidebarHelpMenuItems() {
const { t } = useTranslation();
const shortcutsAvailable = useHardwareKeyboardPresent();
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen);
const [open, setOpen] = useState(false);
const version = formatVersionWithPrefix(resolveAppVersion());
const hosts = useHosts();
@@ -99,105 +115,65 @@ export function SidebarHelpMenu() {
}, []);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<Tooltip delayDuration={300} enabledOnDesktop={!open}>
<TooltipTrigger asChild>
<View>
<DropdownMenuTrigger
style={styles.trigger}
testID="sidebar-help"
accessibilityRole="button"
accessibilityLabel={t("sidebar.help.trigger")}
>
{({ hovered, pressed, open: menuOpen }) => (
<>
<SidebarFooterIconHighlight visible={pressed || menuOpen} />
<DensityIcon
icon={CircleHelp}
size="md"
color={hovered ? "foreground" : "foregroundMuted"}
/>
</>
)}
</DropdownMenuTrigger>
</View>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{t("sidebar.help.trigger")}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="top" align="end" offset={8} width={280} testID="sidebar-help-menu">
<DropdownMenuLabel>{t("sidebar.help.sectionHelp")}</DropdownMenuLabel>
{shortcutsAvailable ? (
<DropdownMenuItem
testID="sidebar-help-shortcuts"
leading={shortcutsLeadingIcon}
onSelect={openKeyboardShortcuts}
>
{t("sidebar.help.shortcuts")}
</DropdownMenuItem>
) : null}
<>
<DropdownMenuLabel>{t("sidebar.help.sectionHelp")}</DropdownMenuLabel>
{shortcutsAvailable ? (
<DropdownMenuItem
testID="sidebar-help-changelog"
leading={changelogLeadingIcon}
onSelect={openChangelog}
testID="sidebar-help-shortcuts"
leading={shortcutsLeadingIcon}
onSelect={openKeyboardShortcuts}
>
{t("sidebar.help.whatsNew")}
{t("sidebar.help.shortcuts")}
</DropdownMenuItem>
<DropdownMenuItem
testID="sidebar-help-diagnostics"
leading={diagnosticLeadingIcon}
onSelect={openAppDiagnostic}
) : null}
<DropdownMenuItem
testID="sidebar-help-changelog"
leading={changelogLeadingIcon}
onSelect={openChangelog}
>
{t("sidebar.help.whatsNew")}
</DropdownMenuItem>
<DropdownMenuItem
testID="sidebar-help-diagnostics"
leading={diagnosticLeadingIcon}
onSelect={openAppDiagnostic}
>
{t("sidebar.help.diagnostics")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("sidebar.help.reportIssue")}</DropdownMenuLabel>
<DropdownMenuItem
testID="sidebar-help-discord"
leading={discordLeadingIcon}
onSelect={openDiscord}
>
{t("sidebar.help.discord")}
</DropdownMenuItem>
<DropdownMenuItem
testID="sidebar-help-github"
leading={githubLeadingIcon}
onSelect={openGitHubIssue}
>
{t("sidebar.help.github")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<View style={styles.versionList}>
<DropdownMenuHint
style={styles.versionHint}
trailing={version}
testID="sidebar-help-version"
>
{t("sidebar.help.diagnostics")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("sidebar.help.reportIssue")}</DropdownMenuLabel>
<DropdownMenuItem
testID="sidebar-help-discord"
leading={discordLeadingIcon}
onSelect={openDiscord}
>
{t("sidebar.help.discord")}
</DropdownMenuItem>
<DropdownMenuItem
testID="sidebar-help-github"
leading={githubLeadingIcon}
onSelect={openGitHubIssue}
>
{t("sidebar.help.github")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<View style={styles.versionList}>
<DropdownMenuHint
style={styles.versionHint}
trailing={version}
testID="sidebar-help-version"
>
{t("sidebar.help.appName")}
</DropdownMenuHint>
{hosts.map((host) => (
<HostVersionHint key={host.serverId} host={host} />
))}
</View>
</DropdownMenuContent>
</DropdownMenu>
{t("sidebar.help.appName")}
</DropdownMenuHint>
{hosts.map((host) => (
<HostVersionHint key={host.serverId} host={host} />
))}
</View>
</>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: density.iconButton,
height: density.iconButton,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
},
versionList: {
gap: theme.spacing[1],
paddingVertical: theme.spacing[2],
@@ -872,6 +872,7 @@ function StatusWorkspaceRowInnerContent({
const {
trailingPresentation,
showKebab: showKebabInSlot,
showArchiveButton,
showScrim,
renderSlot,
reserveSlotWidth,
@@ -951,6 +952,7 @@ function StatusWorkspaceRowInnerContent({
trailing={trailing}
trailingPresentation={trailingPresentation}
showKebab={showKebabInSlot}
showArchiveButton={showArchiveButton}
showScrim={showScrim}
reserveSlotWidth={reserveSlotWidth}
isPinned={isPinned}
@@ -982,6 +984,7 @@ function StatusWorkspaceActionSlot({
trailing,
trailingPresentation,
showKebab,
showArchiveButton,
showScrim,
reserveSlotWidth,
isPinned,
@@ -1002,6 +1005,7 @@ function StatusWorkspaceActionSlot({
trailing: SidebarWorkspaceTrailing;
trailingPresentation: SidebarWorkspaceTrailingPresentation;
showKebab: boolean;
showArchiveButton: boolean;
showScrim: boolean;
reserveSlotWidth: boolean;
isPinned?: boolean;
@@ -1026,6 +1030,8 @@ function StatusWorkspaceActionSlot({
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
workspaceKey={workspace.workspaceKey}
onArchiveButtonPress={showArchiveButton ? onArchive : undefined}
>
{kebab.showKebab && onArchive ? (
<SidebarWorkspaceMenu
@@ -1,6 +1,6 @@
import { useMemo, type ComponentProps, type PropsWithChildren, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { type PressableStateCallbackType } from "react-native";
import type { PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
Archive,
@@ -13,6 +13,7 @@ import {
PinOff,
Tag,
} from "lucide-react-native";
import { PressHighlight } from "@/components/ui/press-highlight";
import { isWeb } from "@/constants/platform";
import { getForgePresentation, normalizeForge } from "@/git/forge";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
@@ -72,6 +73,9 @@ const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedCol
const pinLeadingIcon = <ThemedPin size={14} uniProps={foregroundMutedColorMapping} />;
const unpinLeadingIcon = <ThemedPinOff size={14} uniProps={foregroundMutedColorMapping} />;
/** Pointer-only, so it is always the kebab's pointer size. */
export const ARCHIVE_BUTTON_SIZE = 18;
const triggerIconProps = densityIconProps("sm", foregroundMutedColorMapping);
const triggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping);
@@ -79,6 +83,33 @@ function renderTriggerIcon({ hovered }: { hovered?: boolean }) {
return <ThemedMoreVertical uniProps={hovered ? triggerIconHoveredProps : triggerIconProps} />;
}
function renderArchiveIcon({ hovered }: PressableStateCallbackType & { hovered?: boolean }) {
return <ThemedArchive uniProps={hovered ? triggerIconHoveredProps : triggerIconProps} />;
}
/** One-click archive beside the kebab, for pointer rows that reveal it on hover. */
export function SidebarWorkspaceArchiveButton({
workspaceKey,
onArchive,
}: {
workspaceKey: string;
onArchive: () => void;
}) {
const { t } = useTranslation();
return (
<PressHighlight
hitSlop={4}
style={archiveButtonStyle}
onPress={onArchive}
accessibilityRole={isWeb ? undefined : "button"}
accessibilityLabel={t("sidebar.workspace.actions.archiveWorkspace")}
testID={`sidebar-workspace-archive-button-${workspaceKey}`}
>
{renderArchiveIcon}
</PressHighlight>
);
}
export interface SidebarWorkspaceMenuProps {
workspaceKey: string;
serverId?: string;
@@ -437,6 +468,12 @@ function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovere
return [styles.trigger, hovered && styles.triggerHovered];
}
function archiveButtonStyle({
hovered = false,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.archiveButton, hovered && styles.triggerHovered];
}
const styles = StyleSheet.create((theme) => ({
trigger: {
// Larger on touch so the kebab is an easy tap target.
@@ -455,4 +492,13 @@ const styles = StyleSheet.create((theme) => ({
triggerHovered: {
backgroundColor: theme.colors.surface2,
},
// The kebab's box without its offsets: the archive glyph fills its view box, so it needs
// none of the nudging the three dots do. The kebab's marginLeft is the gap between them.
archiveButton: {
width: ARCHIVE_BUTTON_SIZE,
height: ARCHIVE_BUTTON_SIZE,
alignItems: "center",
justifyContent: "center",
borderRadius: 4,
},
}));
@@ -29,7 +29,11 @@ import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { StatusRing } from "@/components/status-ring";
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
import { DensityIcon } from "@/components/ui/density-icon";
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
import { SCRIM_WIDTH, TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
import {
ARCHIVE_BUTTON_SIZE,
SidebarWorkspaceArchiveButton,
} from "@/components/sidebar/sidebar-workspace-menu";
import { useWorkspaceLabelDefinitions } from "@/workspace-labels";
const needsInputColorMapping = (theme: Theme) => ({
@@ -156,7 +160,11 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
)}
<View style={styles.workspaceContentColumn}>
<View style={styles.workspaceTitleRow}>
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
<Text
style={workspaceBranchTextStyle}
numberOfLines={1}
testID={`sidebar-workspace-title-${workspace.workspaceKey}`}
>
{workspaceLabel}
</Text>
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
@@ -330,6 +338,8 @@ export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
position: "absolute",
top: 0,
right: 0,
flexDirection: "row",
alignItems: "flex-start",
},
}));
@@ -370,6 +380,7 @@ export function resolveTrailingActionVisibility({
}): {
trailingPresentation: SidebarWorkspaceTrailingPresentation;
showKebab: boolean;
showArchiveButton: boolean;
showScrim: boolean;
renderSlot: boolean;
reserveSlotWidth: boolean;
@@ -384,6 +395,9 @@ export function resolveTrailingActionVisibility({
return {
trailingPresentation,
showKebab,
// One-click archive is a pointer affordance: on touch it would sit a few pixels from the
// kebab and turn a miss into an archive.
showArchiveButton: showKebab && isHovered && !isTouchPlatform,
// The scrim paints the row's own hover background, so it can only be drawn on a hovered
// row — over an unhovered one the gradient fades to the wrong color. That is also why
// touch, which shows the kebab without ever hovering, never gets one.
@@ -432,23 +446,46 @@ export function SidebarWorkspaceTrailingActionBase({
);
}
// The archive button plus the kebab's marginLeft, which is the gap between them.
const ARCHIVE_BUTTON_SCRIM_WIDTH = ARCHIVE_BUTTON_SIZE + 2;
export function SidebarWorkspaceTrailingActionOverlay({
visible,
scrimBackdrop,
workspaceKey,
onArchiveButtonPress,
children,
}: {
visible: boolean;
/** Fade the row into the kebab when something (the diff stat) is still rendered behind it. */
scrimBackdrop?: SidebarSurfaceBackdrop;
workspaceKey: string;
/**
* Shows the archive button left of the kebab. It overlays the title like the kebab does, so
* the scrim widens to cover it rather than the slot reserving its width.
*/
onArchiveButtonPress?: () => void;
children: ReactNode;
}) {
if (!visible || !children) return null;
return (
<>
{scrimBackdrop ? (
<TrailingActionScrim backdrop={scrimBackdrop} testID="sidebar-workspace-trailing-scrim" />
<TrailingActionScrim
backdrop={scrimBackdrop}
width={onArchiveButtonPress ? SCRIM_WIDTH + ARCHIVE_BUTTON_SCRIM_WIDTH : SCRIM_WIDTH}
testID="sidebar-workspace-trailing-scrim"
/>
) : null}
<View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>{children}</View>
<View style={sidebarWorkspaceRowStyles.trailingActionOverlay}>
{onArchiveButtonPress ? (
<SidebarWorkspaceArchiveButton
workspaceKey={workspaceKey}
onArchive={onArchiveButtonPress}
/>
) : null}
{children}
</View>
</>
);
}
@@ -404,6 +404,7 @@ function WorkspaceRowTrailingActions({
const {
trailingPresentation,
showKebab: showKebabInSlot,
showArchiveButton,
showScrim,
renderSlot,
reserveSlotWidth,
@@ -430,6 +431,8 @@ function WorkspaceRowTrailingActions({
<SidebarWorkspaceTrailingActionOverlay
visible={kebab.showKebab}
scrimBackdrop={showScrim ? backdrop : undefined}
workspaceKey={workspace.workspaceKey}
onArchiveButtonPress={showArchiveButton ? onArchive : undefined}
>
{onArchive ? (
<SidebarWorkspaceMenu
+50 -31
View File
@@ -35,6 +35,8 @@ export interface ToastShowOptions {
durationMs?: number | null;
nativeAndroid?: boolean;
testID?: string;
/** Runs once when this toast leaves the screen: timed out, dismissed, or replaced. */
onDismiss?: () => void;
}
export interface ToastState {
@@ -45,6 +47,7 @@ export interface ToastState {
variant: ToastVariant;
durationMs: number | null;
testID?: string;
onDismiss?: () => void;
}
/** Dismisses the toast it was returned for; a no-op once another toast replaced it. */
@@ -78,40 +81,56 @@ export function useToastHost(): {
dismiss: () => void;
} {
const { t } = useTranslation();
const [toast, setToast] = useState<ToastState | null>(null);
const [toast, setToastState] = useState<ToastState | null>(null);
const toastRef = useRef<ToastState | null>(null);
const idRef = useRef(0);
const show = useCallback((content: ReactNode, options?: ToastShowOptions): DismissToast => {
const nativeMessage = typeof content === "string" ? content.trim() : null;
if (!content || nativeMessage === "") {
return noopDismiss;
}
const variant = options?.variant ?? "default";
const durationMs = options?.durationMs === undefined ? DEFAULT_DURATION_MS : options.durationMs;
const nativeAndroid = options?.nativeAndroid ?? false;
if (Platform.OS === "android" && nativeAndroid && nativeMessage) {
const duration =
durationMs !== null && durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG;
ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP);
return noopDismiss;
}
idRef.current += 1;
const id = idRef.current;
setToast({
id,
content,
nativeMessage,
icon: options?.icon,
variant,
durationMs,
testID: options?.testID,
});
return () => setToast((current) => (current?.id === id ? null : current));
const setToast = useCallback((next: ToastState | null) => {
const previous = toastRef.current;
if (previous === next) return;
toastRef.current = next;
setToastState(next);
previous?.onDismiss?.();
}, []);
const show = useCallback(
(content: ReactNode, options?: ToastShowOptions): DismissToast => {
const nativeMessage = typeof content === "string" ? content.trim() : null;
if (!content || nativeMessage === "") {
return noopDismiss;
}
const variant = options?.variant ?? "default";
const durationMs =
options?.durationMs === undefined ? DEFAULT_DURATION_MS : options.durationMs;
const nativeAndroid = options?.nativeAndroid ?? false;
if (Platform.OS === "android" && nativeAndroid && nativeMessage) {
const duration =
durationMs !== null && durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG;
ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP);
return noopDismiss;
}
idRef.current += 1;
const id = idRef.current;
setToast({
id,
content,
nativeMessage,
icon: options?.icon,
variant,
durationMs,
testID: options?.testID,
onDismiss: options?.onDismiss,
});
return () => {
if (toastRef.current?.id === id) setToast(null);
};
},
[setToast],
);
const api = useMemo<ToastApi>(
() => ({
show,
@@ -127,7 +146,7 @@ export function useToastHost(): {
const dismiss = useCallback(() => {
setToast(null);
}, []);
}, [setToast]);
return { api, toast, dismiss };
}
+24 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { computePosition, getTransformOrigin, type Rect } from "./anchor";
import { computePosition, fitSurfaceWidth, getTransformOrigin, type Rect } from "./anchor";
const DISPLAY: Rect = { x: 0, y: 0, width: 1000, height: 800 };
const TRIGGER: Rect = { x: 100, y: 100, width: 40, height: 20 };
@@ -97,3 +97,26 @@ describe("getTransformOrigin", () => {
expect(getTransformOrigin(placement, alignment)).toEqual(expected);
});
});
describe("fitSurfaceWidth", () => {
it("leaves a surface that fits the display alone, capped at the display", () => {
expect(fitSurfaceWidth({ width: 240, minWidth: 180 }, 1000)).toEqual({
width: 240,
minWidth: 180,
maxWidth: 984,
});
});
it("shrinks a surface wider than a narrow display to the space inside its edge padding", () => {
expect(fitSurfaceWidth({ width: 240, minWidth: 180, maxWidth: 320 }, 220)).toEqual({
width: 204,
minWidth: 180,
maxWidth: 204,
});
expect(fitSurfaceWidth({ minWidth: 180 }, 150)).toEqual({
width: undefined,
minWidth: 134,
maxWidth: 134,
});
});
});
+21
View File
@@ -120,6 +120,27 @@ function clampToDisplayArea(input: {
};
}
export interface SurfaceWidth {
width?: number;
minWidth?: number;
maxWidth?: number;
}
/**
* Caps a surface's requested width to the display. Position clamping can move a surface but not
* shrink it, so a menu wider than a narrow screen would otherwise hang off the far edge.
*/
export function fitSurfaceWidth(requested: SurfaceWidth, displayWidth: number): SurfaceWidth {
const available = Math.max(displayWidth - EDGE_PADDING * 2, 0);
const cap = (value: number | undefined) =>
typeof value === "number" ? Math.min(value, available) : undefined;
return {
width: cap(requested.width),
minWidth: cap(requested.minWidth),
maxWidth: cap(requested.maxWidth) ?? available,
};
}
export function computePosition({
triggerRect,
contentSize,
@@ -12,7 +12,11 @@ import { Portal } from "@gorhom/portal";
import Animated, { useAnimatedReaction, useAnimatedStyle } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete";
import {
Autocomplete,
type AutocompleteOption,
type AutocompleteStatus,
} from "@/components/ui/autocomplete";
import {
measureFloatingPanelPortalHost,
useFloatingPanelPortalHostName,
@@ -37,10 +41,7 @@ interface AutocompletePopoverProps {
options: readonly AutocompleteOption[];
selectedIndex: number;
onSelect: (option: AutocompleteOption) => void;
isLoading?: boolean;
errorMessage?: string;
loadingText?: string;
emptyText?: string;
status?: AutocompleteStatus | null;
}
export function AutocompletePopover({
@@ -50,10 +51,7 @@ export function AutocompletePopover({
options,
selectedIndex,
onSelect,
isLoading,
errorMessage,
loadingText,
emptyText,
status,
}: AutocompletePopoverProps): ReactElement | null {
"use no memo";
// React Compiler memoizes effect captures by reading SharedValue.value during render.
@@ -154,10 +152,7 @@ export function AutocompletePopover({
options={options}
selectedIndex={selectedIndex}
onSelect={onSelect}
isLoading={isLoading}
errorMessage={errorMessage}
loadingText={loadingText}
emptyText={emptyText}
status={status}
/>
</Animated.View>
</KeyboardTranslateView>
+197 -180
View File
@@ -9,32 +9,79 @@ import {
type NativeSyntheticEvent,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { File, Folder } from "lucide-react-native";
import {
Archive,
CircleAlert,
File,
Folder,
Puzzle,
RotateCw,
Sparkles,
SquarePen,
SquareSlash,
} from "lucide-react-native";
import type { MatchRange } from "@getpaseo/protocol/search/text-match";
import { useIsCompactFormFactor } from "@/constants/layout";
import type { Theme } from "@/styles/theme";
import { Button } from "./button";
import { HighlightedText } from "./highlighted-text";
import { mutedIconColorMapping } from "./icon-color";
import { LoadingSpinner } from "./loading-spinner";
import { getScrollOffsetToRevealItem } from "./scroll-reveal";
import { StatusBadge } from "./status-badge";
export type AutocompleteOptionIcon =
| "command"
| "skill"
| "plugin"
| "archive"
| "new-draft"
| "file"
| "directory";
export interface AutocompleteOption {
id: string;
label: string;
detail?: string;
icon: AutocompleteOptionIcon;
/** Spans of `label` the query matched. */
labelMatches?: readonly MatchRange[];
argumentHint?: string;
description?: string;
kind?: "command" | "file" | "directory";
/** Where the option comes from, when that is not the provider itself: `Skill`, `Paseo`, a plugin. */
tag?: string;
}
/** What the list shows in place of options. */
export type AutocompleteStatus =
| { kind: "loading"; message: string }
| { kind: "empty"; message: string }
| { kind: "error"; title: string; reason?: string; onRetry?: () => void };
interface AutocompleteProps {
options: readonly AutocompleteOption[];
selectedIndex: number;
onSelect: (option: AutocompleteOption) => void;
isLoading?: boolean;
errorMessage?: string;
loadingText?: string;
emptyText?: string;
status?: AutocompleteStatus | null;
maxHeight?: number;
}
const BOLT_GLYPH_PATTERN = /\u26A1|\uFE0F/gu;
const ROW_ICONS = {
command: withUnistyles(SquareSlash, mutedIconColorMapping),
skill: withUnistyles(Sparkles, mutedIconColorMapping),
plugin: withUnistyles(Puzzle, mutedIconColorMapping),
archive: withUnistyles(Archive, mutedIconColorMapping),
"new-draft": withUnistyles(SquarePen, mutedIconColorMapping),
file: withUnistyles(File, mutedIconColorMapping),
directory: withUnistyles(Folder, mutedIconColorMapping),
} satisfies Record<AutocompleteOptionIcon, unknown>;
const ErrorIcon = withUnistyles(CircleAlert, mutedIconColorMapping);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, mutedIconColorMapping);
const ROW_ICON_SIZE = 14;
const BOLT_GLYPH_PATTERN = /⚡|️/gu;
function removeBoltGlyphs(value?: string): string | undefined {
if (!value) {
@@ -48,7 +95,7 @@ interface AutocompleteRowProps {
index: number;
option: AutocompleteOption;
isSelected: boolean;
mutedColor: string;
showArgumentHint: boolean;
onSelect: (option: AutocompleteOption) => void;
onRowLayout: (index: number, event: LayoutChangeEvent) => void;
}
@@ -57,13 +104,15 @@ function AutocompleteRow({
index,
option,
isSelected,
mutedColor,
showArgumentHint,
onSelect,
onRowLayout,
}: AutocompleteRowProps) {
const optionLabel = removeBoltGlyphs(option.label) ?? option.label;
const optionDescription = removeBoltGlyphs(option.description);
const isFileOrDir = option.kind === "directory" || option.kind === "file";
const label = removeBoltGlyphs(option.label) ?? option.label;
const description = removeBoltGlyphs(option.description);
const argumentHint = showArgumentHint ? removeBoltGlyphs(option.argumentHint) : undefined;
const Icon = ROW_ICONS[option.icon];
const labelMatches = label === option.label ? option.labelMatches : undefined;
const handleLayout = useCallback(
(event: LayoutChangeEvent) => onRowLayout(index, event),
@@ -79,41 +128,77 @@ function AutocompleteRow({
);
return (
<Pressable onLayout={handleLayout} onPress={handlePress} style={pressableStyle}>
{isFileOrDir ? (
<>
<View style={styles.itemLeading}>
{option.kind === "directory" ? (
<Folder size={14} color={mutedColor} />
) : (
<File size={14} color={mutedColor} />
)}
</View>
<View style={styles.itemMain}>
<View style={styles.itemHeader}>
<Text style={styles.itemLabel}>{optionLabel}</Text>
{removeBoltGlyphs(option.detail) ? (
<Text style={styles.itemDetail}>{removeBoltGlyphs(option.detail)}</Text>
) : null}
</View>
{optionDescription ? (
<Text style={styles.itemDescription} numberOfLines={1}>
{optionDescription}
</Text>
) : null}
</View>
</>
) : (
<View style={styles.itemMainRow}>
<Text style={styles.itemLabel}>{optionLabel}</Text>
{optionDescription ? (
<Text style={styles.itemDescriptionInline} numberOfLines={1}>
{optionDescription}
<Pressable
onLayout={handleLayout}
onPress={handlePress}
style={pressableStyle}
// Name and description only; the hint and tag repeat what the name implies.
accessibilityLabel={description ? `${label}, ${description}` : label}
>
<View style={styles.itemIcon}>
<Icon size={ROW_ICON_SIZE} />
</View>
<HighlightedText
text={label}
ranges={labelMatches}
style={styles.itemLabel}
numberOfLines={1}
/>
{argumentHint ? (
<Text style={styles.itemArgumentHint} numberOfLines={1}>
{argumentHint}
</Text>
) : null}
<Text style={styles.itemDescription} numberOfLines={1}>
{description ?? ""}
</Text>
{option.tag ? <StatusBadge label={option.tag} size="xs" /> : null}
</Pressable>
);
}
function AutocompleteStatusRow({ status }: { status: AutocompleteStatus }) {
const { t } = useTranslation();
if (status.kind === "error") {
return (
<View style={styles.statusRow} testID="composer-autocomplete-error">
<View style={styles.itemIcon}>
<ErrorIcon size={ROW_ICON_SIZE} />
</View>
<View style={styles.statusText}>
<Text style={styles.statusTitle} numberOfLines={2}>
{status.title}
</Text>
{status.reason ? (
<Text style={styles.statusReason} numberOfLines={2}>
{status.reason}
</Text>
) : null}
</View>
)}
</Pressable>
{status.onRetry ? (
<Button variant="secondary" size="sm" leftIcon={RotateCw} onPress={status.onRetry}>
{t("common.actions.retry")}
</Button>
) : null}
</View>
);
}
return (
<View
style={styles.statusRow}
testID={status.kind === "loading" ? "composer-autocomplete-loading" : undefined}
>
{status.kind === "loading" ? (
<View style={styles.itemIcon}>
<ThemedLoadingSpinner size={ROW_ICON_SIZE} />
</View>
) : null}
<Text style={styles.statusMessage} numberOfLines={2}>
{status.message}
</Text>
</View>
);
}
@@ -121,16 +206,11 @@ export function Autocomplete({
options,
selectedIndex,
onSelect,
isLoading = false,
errorMessage,
loadingText,
emptyText,
status,
maxHeight = 220,
}: AutocompleteProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const resolvedLoadingText = loadingText ?? t("common.states.loading");
const resolvedEmptyText = emptyText ?? t("common.empty.noResults");
const isCompact = useIsCompactFormFactor();
const scrollRef = useRef<ScrollView>(null);
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map());
const viewportHeightRef = useRef(0);
@@ -210,117 +290,51 @@ export function Autocomplete({
[ensureActiveItemVisible],
);
const selectedOption = options[selectedIndex];
const containerStyle = useMemo(() => [styles.container, { maxHeight }], [maxHeight]);
if (isLoading) {
return (
<View style={containerStyle}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>{resolvedLoadingText}</Text>
</View>
</View>
);
}
const noResultsMessage = t("common.empty.noResults");
const shownStatus = useMemo<AutocompleteStatus | null>(
() => status ?? (options.length === 0 ? { kind: "empty", message: noResultsMessage } : null),
[noResultsMessage, options.length, status],
);
if (errorMessage) {
if (shownStatus) {
return (
<View style={containerStyle}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>Error: {errorMessage}</Text>
</View>
</View>
);
}
if (options.length === 0) {
return (
<View style={containerStyle}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>{resolvedEmptyText}</Text>
</View>
<AutocompleteStatusRow status={shownStatus} />
</View>
);
}
return (
<View style={styles.outerWrapper}>
{selectedOption?.kind === "command" && selectedOption.description ? (
<View style={styles.detailCard}>
<Text style={styles.detailLabel} numberOfLines={1}>
{removeBoltGlyphs(selectedOption.label) ?? selectedOption.label}
</Text>
<Text style={styles.detailDescription} numberOfLines={2} ellipsizeMode="tail">
{removeBoltGlyphs(selectedOption.description)}
</Text>
{selectedOption.detail ? (
<Text style={styles.detailHint} numberOfLines={1}>
{removeBoltGlyphs(selectedOption.detail)}
</Text>
) : null}
</View>
) : null}
<View style={containerStyle}>
<ScrollView
ref={scrollRef}
onLayout={handleScrollViewLayout}
onContentSizeChange={pinToBottom}
onScroll={handleScroll}
scrollEventThrottle={16}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="always"
>
{options.map((option, index) => (
<AutocompleteRow
key={option.id}
index={index}
option={option}
isSelected={index === selectedIndex}
mutedColor={theme.colors.foregroundMuted}
onSelect={onSelect}
onRowLayout={handleRowLayout}
/>
))}
</ScrollView>
</View>
<View style={containerStyle}>
<ScrollView
ref={scrollRef}
onLayout={handleScrollViewLayout}
onContentSizeChange={pinToBottom}
onScroll={handleScroll}
scrollEventThrottle={16}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="always"
>
{options.map((option, index) => (
<AutocompleteRow
key={option.id}
index={index}
option={option}
isSelected={index === selectedIndex}
showArgumentHint={!isCompact}
onSelect={onSelect}
onRowLayout={handleRowLayout}
/>
))}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create((theme: Theme) => ({
outerWrapper: {
flexShrink: 1,
minHeight: 0,
gap: theme.spacing[1],
},
detailCard: {
flexShrink: 1,
minHeight: 0,
overflow: "hidden",
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
...theme.shadow.md,
},
detailLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
detailDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
},
detailHint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
},
container: {
flexShrink: 1,
// Keep a complete selectable row (36), list padding (8), and borders (2).
@@ -342,60 +356,63 @@ const styles = StyleSheet.create((theme: Theme) => ({
item: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 36,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
itemLeading: {
width: 18,
alignItems: "center",
justifyContent: "center",
marginRight: theme.spacing[1],
},
itemActive: {
backgroundColor: theme.colors.surface2,
},
itemMain: {
flex: 1,
minWidth: 0,
},
itemMainRow: {
flex: 1,
minWidth: 0,
flexDirection: "row",
itemIcon: {
width: 16,
alignItems: "center",
gap: theme.spacing[2],
},
itemHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
justifyContent: "center",
},
itemLabel: {
flexShrink: 0,
maxWidth: "60%",
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
},
itemDetail: {
itemArgumentHint: {
flexShrink: 0,
maxWidth: "30%",
color: theme.colors.foregroundMuted,
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.sm,
},
itemDescription: {
flex: 1,
minWidth: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
marginTop: 2,
},
itemDescriptionInline: {
statusRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: 44,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
statusText: {
flex: 1,
minWidth: 0,
gap: 2,
},
statusTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
statusReason: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
statusMessage: {
flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
emptyItem: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[3],
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
})) as unknown as Record<string, object>;
@@ -12,8 +12,19 @@ import {
resolveControlSizing,
switchGeometryForLayout,
getControlInteractionPhase,
TOOLBAR_CONTROL_GAP,
TOOLBAR_CONTROL_GAPS,
TOOLBAR_CONTROL_HEIGHT,
TOOLBAR_CONTROL_SIZES,
TOOLBAR_DISC_GLYPH_SIZES,
TOOLBAR_DISC_SIZE,
TOOLBAR_DISC_SIZES,
TOOLBAR_GLYPH_SIZES,
TOOLBAR_RINGS,
toolbarGlyphColor,
toolbarGlyphsFor,
toolbarGlyphTone,
toolbarSlot,
TOUCH_TARGET,
} from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
@@ -90,6 +101,11 @@ describe("control sizing gate", () => {
// Composer toolbar controls: a full target on touch, unchanged 28 on pointer.
expect(TOOLBAR_CONTROL_SIZES).toEqual({ touch: TOUCH_TARGET, pointer: 28 });
expect(TOOLBAR_CONTROL_HEIGHT).toEqual({ xs: TOUCH_TARGET, md: 28 });
// A filled toolbar control paints a 36pt disc inside its 44pt touch frame, and fills its
// 28pt pointer frame exactly, so desktop is unchanged. Its glyph is the `md` glyph.
expect(TOOLBAR_DISC_SIZES).toEqual({ touch: 36, pointer: TOOLBAR_CONTROL_SIZES.pointer });
expect(TOOLBAR_DISC_SIZE).toEqual({ xs: 36, md: 28 });
expect(iconSizeFor("md")).toEqual({ touch: 20, pointer: 16 });
for (const size of ["sm", "md"] as const) {
expect(controlHeightForLayout(size, true)).toBeGreaterThan(
controlHeightForLayout(size, false),
@@ -102,6 +118,62 @@ describe("control sizing gate", () => {
});
});
describe("toolbar glyphs and spacing", () => {
it("spaces touch toolbar controls by what they paint and keeps the pointer's 4px gap", () => {
expect(TOOLBAR_CONTROL_GAPS).toEqual({ touch: 20, pointer: 4 });
expect(TOOLBAR_CONTROL_GAP).toEqual({ xs: 20, md: 4 });
// A 20pt glyph's disc-sized frame reaches 8 into the gap on each side.
expect(toolbarSlot(20)).toEqual({
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
marginHorizontal: { xs: -8, md: 0 },
});
});
it("paints touch toolbar glyphs at the header icons' 20pt and 1.5px stroke and leaves pointer unchanged", () => {
const pointerGlyph = { size: 16, strokeWidth: 1.5, tone: "muted" };
expect(toolbarGlyphsFor("touch").icon).toEqual({
size: 20,
strokeWidth: 1.5,
tone: "foreground",
});
expect(toolbarGlyphsFor("pointer")).toEqual({ icon: pointerGlyph, label: pointerGlyph });
expect(TOOLBAR_GLYPH_SIZES.icon).toEqual({ xs: 20, md: 16 });
expect(toolbarGlyphsFor("touch").icon.size).toBeLessThan(TOOLBAR_CONTROL_SIZES.touch);
expect(toolbarGlyphsFor("pointer").icon.size).toBeLessThan(TOOLBAR_CONTROL_SIZES.pointer);
// A disc's solid glyph stays at the `md` size so the stop square does not fill its disc.
expect(TOOLBAR_DISC_GLYPH_SIZES).toEqual({ touch: 20, pointer: 16 });
});
it("keeps a touch glyph beside a label at the label's 20pt scale", () => {
expect(toolbarGlyphsFor("touch").label).toEqual({
size: 20,
strokeWidth: 1.5,
tone: "foreground",
});
expect(TOOLBAR_GLYPH_SIZES.label).toEqual({ xs: 20, md: 16 });
});
it("sizes the touch context ring to the toolbar glyph's ink and keeps the pointer ring", () => {
expect(TOOLBAR_RINGS).toEqual({
touch: { size: 18, strokeWidth: 2 },
pointer: { size: 16, strokeWidth: 2 },
});
});
it("brightens a muted toolbar glyph under a hovering pointer", () => {
const pointer = toolbarGlyphsFor("pointer").icon;
const touch = toolbarGlyphsFor("touch").icon;
expect(toolbarGlyphTone(pointer, false)).toBe("muted");
expect(toolbarGlyphTone(pointer, true)).toBe("foreground");
expect(toolbarGlyphTone(touch, false)).toBe("foreground");
const colors = { foreground: "#fafafa", foregroundMuted: "#a1a5a4" };
const palette = { colors } as unknown as Theme;
expect(toolbarGlyphColor(palette, "foreground")).toBe("#fafafa");
expect(toolbarGlyphColor(palette, "muted")).toBe("#a1a5a4");
});
});
describe("control geometry", () => {
it("keeps resting control borders transparent while preserving border geometry", () => {
const geometry = createControlGeometry(theme);
@@ -1,5 +1,5 @@
import type { StyleProp, ViewStyle } from "react-native";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { ICON_SIZE, ICON_STROKE_WIDTH, type Theme } from "@/styles/theme";
import {
bySizing,
controlSizingForLayout,
@@ -87,12 +87,63 @@ export const ICON_BUTTON_SIZES = {
} satisfies Record<"large" | "small", SizingValue>;
/**
* A control in a dense toolbar row, such as the composer's: an icon-only square or a labelled
* pill. Touch-sized it is one touch target.
* The height of a dense toolbar row, such as the composer's. Touch-sized it is one touch target;
* its controls are disc-sized and centre in it.
*/
export const TOOLBAR_CONTROL_SIZES = { touch: TOUCH_TARGET, pointer: 28 } satisfies SizingValue;
export const TOOLBAR_CONTROL_HEIGHT = bySizing(TOOLBAR_CONTROL_SIZES);
/**
* The disc of a filled toolbar control, such as the composer's send and stop, and the frame and
* highlight of every other toolbar control (`toolbarSlot`). Pointer-sized it fills the row.
*/
export const TOOLBAR_DISC_SIZES = { touch: 36, pointer: 28 } satisfies SizingValue;
export const TOOLBAR_DISC_SIZE = bySizing(TOOLBAR_DISC_SIZES);
/**
* The space between adjacent toolbar controls. Touch-sized it is the visible space between what
* the controls paint, because each control lays out at its painted width (`toolbarSlot`).
*/
export const TOOLBAR_CONTROL_GAPS = { touch: 20, pointer: 4 } satisfies SizingValue;
export const TOOLBAR_CONTROL_GAP = bySizing(TOOLBAR_CONTROL_GAPS);
/**
* A toolbar control's frame: a disc-sized circle, which is also its fully rounded highlight.
* Touch-sized it lays out at the width of the glyph it paints, `shapeWidth`, and the circle reaches
* into the gaps on either side, so the row spaces what you see. Pointer-sized it is the square
* control.
*/
export function toolbarSlot(shapeWidth: number): {
width: LayoutValue;
height: LayoutValue;
marginHorizontal: LayoutValue;
} {
return {
width: TOOLBAR_DISC_SIZE,
height: TOOLBAR_DISC_SIZE,
marginHorizontal: bySizing({
touch: (shapeWidth - TOOLBAR_DISC_SIZES.touch) / 2,
pointer: 0,
}),
};
}
/**
* A toolbar control as wide as its content, such as a labelled picker: a disc-tall pill. Touch-sized
* its padding reaches into the gaps, so its content is what the row spaces.
*/
export function toolbarContentSlot(padding: number): {
height: LayoutValue;
paddingHorizontal: number;
marginHorizontal: LayoutValue;
} {
return {
height: TOOLBAR_DISC_SIZE,
paddingHorizontal: padding,
marginHorizontal: bySizing({ touch: -padding, pointer: 0 }),
};
}
/**
* Glyph sizes. Pointer glyphs sit beside 13-14pt text; touch glyphs sit beside iOS's 17pt body
* text, so each step is larger.
@@ -104,6 +155,81 @@ export function iconSizeFor(size: IconSize): SizingValue {
return { touch: TOUCH_ICON_SIZE[size], pointer: ICON_SIZE[size] };
}
/**
* The glyph a toolbar control paints. Touch-sized every toolbar glyph matches the header icons:
* 20pt at the app's 1.5px stroke, resting at `foreground`. Pointer-sized it is the `md` glyph at
* the same stroke and rests muted until hovered.
*/
export type ToolbarGlyphTone = "foreground" | "muted";
export interface ToolbarGlyph {
size: number;
strokeWidth: number;
/** The tone at rest; a hovering pointer brightens it to `foreground`. */
tone: ToolbarGlyphTone;
}
/** `icon` is an icon-only control's glyph; `label` is a glyph paired with a label. */
export interface ToolbarGlyphs {
icon: ToolbarGlyph;
label: ToolbarGlyph;
}
const POINTER_TOOLBAR_GLYPH: ToolbarGlyph = {
size: ICON_SIZE.md,
strokeWidth: ICON_STROKE_WIDTH,
tone: "muted",
};
const TOOLBAR_GLYPHS: Record<ControlSizing, ToolbarGlyphs> = {
touch: {
icon: { size: TOUCH_ICON_SIZE.md, strokeWidth: ICON_STROKE_WIDTH, tone: "foreground" },
label: { size: TOUCH_ICON_SIZE.md, strokeWidth: ICON_STROKE_WIDTH, tone: "foreground" },
},
pointer: { icon: POINTER_TOOLBAR_GLYPH, label: POINTER_TOOLBAR_GLYPH },
};
export function toolbarGlyphsFor(sizing: ControlSizing): ToolbarGlyphs {
return TOOLBAR_GLYPHS[sizing];
}
/** The slot each toolbar glyph sits in, as stylesheet values. */
export const TOOLBAR_GLYPH_SIZES = {
icon: bySizing({ touch: TOOLBAR_GLYPHS.touch.icon.size, pointer: POINTER_TOOLBAR_GLYPH.size }),
label: bySizing({ touch: TOOLBAR_GLYPHS.touch.label.size, pointer: POINTER_TOOLBAR_GLYPH.size }),
} satisfies Record<keyof ToolbarGlyphs, LayoutValue>;
/**
* The context ring in a toolbar row. A full circle fills its box while an outline glyph's ink
* sits inset, so touch-sized the ring is 18pt, the height of a 20pt glyph's ink. Its 2px stroke
* is half a pixel over the glyphs' so the thin circle does not read lighter than them.
* Pointer-sized it is the `md` glyph.
*/
export interface ToolbarRing {
size: number;
strokeWidth: number;
}
export const TOOLBAR_RINGS = {
touch: { size: 18, strokeWidth: 2 },
pointer: { size: ICON_SIZE.md, strokeWidth: 2 },
} satisfies Record<ControlSizing, ToolbarRing>;
export function toolbarGlyphTone(glyph: ToolbarGlyph, hovered: boolean): ToolbarGlyphTone {
return hovered ? "foreground" : glyph.tone;
}
export function toolbarGlyphColor(theme: Theme, tone: ToolbarGlyphTone): string {
return tone === "foreground" ? theme.colors.foreground : theme.colors.foregroundMuted;
}
/**
* The solid glyph inside a toolbar disc, such as the stop square. It stays at the `md` glyph
* size: at the toolbar glyph size the square would fill half its disc. Send's outline arrow
* takes the toolbar glyph.
*/
export const TOOLBAR_DISC_GLYPH_SIZES = iconSizeFor("md");
/** The height of a control of `size`, for code that has to compute with it outside a stylesheet. */
export function controlHeightForLayout(size: ControlSize, isCompact: boolean): number {
return CONTROL_SIZES[size][controlSizingForLayout(isCompact)];
@@ -29,6 +29,7 @@ import type { KeyboardFocusScope } from "@/keyboard/actions";
import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
import {
computePosition,
fitSurfaceWidth,
getTransformOrigin,
measureElement,
type Alignment,
@@ -283,11 +284,7 @@ export function AnchoredSurface({
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
: {
...(typeof width === "number" ? { width } : null),
...(typeof minWidth === "number" ? { minWidth } : null),
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
: fitSurfaceWidth({ width, minWidth, maxWidth }, screenWidth);
return [
resolvedWidthStyle,
{
@@ -0,0 +1,61 @@
import React, { act, useMemo } from "react";
import { createRoot, type Root } from "react-dom/client";
import { View } from "react-native";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { TailEllipsisText } from "./index";
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(() => {
act(() => root.unmount());
host.remove();
});
const TEXT_STYLE = { fontSize: 16, fontFamily: "monospace" } as const;
function LabelRow({ text, width }: { text: string; width: number }) {
const rowStyle = useMemo(() => ({ width, flexDirection: "row" as const }), [width]);
return (
<View style={rowStyle}>
<TailEllipsisText text={text} style={TEXT_STYLE} testID="label" />
</View>
);
}
function renderLabel(text: string, width: number): void {
act(() => root.render(<LabelRow text={text} width={width} />));
}
function shownLabel(): { text: string; visible: boolean } {
const label = host.querySelector<HTMLElement>('[data-testid="label"]');
if (!label) throw new Error("label did not render");
return { text: label.textContent ?? "", visible: getComputedStyle(label).opacity !== "0" };
}
it("shows a replacement label that shares its shortened prefixes with the previous one", async () => {
// Wide enough for "Ten second…" in monospace, too narrow for either full label.
const width = 12 * 10;
renderLabel("Ten second stream", width);
await vi.waitFor(() => expect(shownLabel()).toEqual({ text: "Ten second…", visible: true }));
renderLabel("Ten second thread", width);
await vi.waitFor(() => expect(shownLabel()).toEqual({ text: "Ten second…", visible: true }));
});
it("shows a replacement label as wide as the previous one", async () => {
renderLabel("Opus 4.7", 400);
await vi.waitFor(() => expect(shownLabel()).toEqual({ text: "Opus 4.7", visible: true }));
renderLabel("Opus 4.8", 400);
await vi.waitFor(() => expect(shownLabel()).toEqual({ text: "Opus 4.8", visible: true }));
});
@@ -0,0 +1,145 @@
import { useCallback, useMemo, useState } from "react";
import { Text, View, type LayoutChangeEvent, type StyleProp, type TextStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { readMeasuredWidth } from "@/hooks/use-container-width";
import { resolveTailEllipsis, tailEllipsisCandidates } from "./resolve";
interface MeasuredWidths {
text: string;
full?: number;
candidates: (number | undefined)[];
}
/**
* One line of text that shrinks with its row and, when it does not fit, ends on a whole word
* and an ellipsis ("Ten second…"), never on a space. It measures itself, so it stays invisible
* for the frame before its widths are known.
*/
export function TailEllipsisText({
text,
style,
testID,
}: {
text: string;
/** Text styles; the component owns its layout. */
style: StyleProp<TextStyle>;
testID?: string;
}) {
const candidates = useMemo(() => tailEllipsisCandidates(text), [text]);
const [boxWidth, setBoxWidth] = useState<number | undefined>(undefined);
const [widths, setWidths] = useState<MeasuredWidths>({ text, candidates: [] });
const current = widths.text === text ? widths : { text, candidates: [] };
const onBoxLayout = useCallback((event: LayoutChangeEvent) => {
const width = readMeasuredWidth(event);
if (width !== null) setBoxWidth(width);
}, []);
const measure = useCallback(
(slot: "full" | number) => (event: LayoutChangeEvent) => {
const width = readMeasuredWidth(event);
if (width === null) return;
setWidths((previous) => {
const base = previous.text === text ? previous : { text, candidates: [] };
if (slot === "full") return { ...base, full: width };
const next = [...base.candidates];
next[slot] = width;
return { ...base, candidates: next };
});
},
[text],
);
const decision = resolveTailEllipsis({
candidates,
measurements: { boxWidth, fullWidth: current.full, candidateWidths: current.candidates },
});
const shownText = decision.kind === "truncated" ? decision.text : text;
return (
<View style={styles.box} onLayout={onBoxLayout}>
{/* Sizes the box: as wide as the text, or as the row allows. */}
<Text
style={[style, styles.sizer]}
numberOfLines={1}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
{text}
</Text>
<View style={styles.shown} pointerEvents="none">
<Text
style={[style, styles.shownText, decision.kind === "measuring" && styles.measuring]}
numberOfLines={1}
ellipsizeMode="tail"
testID={testID}
>
{shownText}
</Text>
</View>
{/* Keyed by the text, so new text remounts the measurer and every width is measured again.
A retained node whose size does not change emits no new layout event. */}
<View
key={text}
style={styles.measurer}
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Text style={[style, styles.measured]} numberOfLines={1} onLayout={measure("full")}>
{text}
</Text>
{candidates.map((candidate, index) => (
<Text
key={candidate}
style={[style, styles.measured]}
numberOfLines={1}
onLayout={measure(index)}
>
{candidate}
</Text>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
// A row, so the absolute measurer below lays its text out at full width on native too.
box: {
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
},
sizer: {
minWidth: 0,
flexShrink: 1,
opacity: 0,
},
shown: {
position: "absolute",
left: 0,
right: 0,
top: 0,
bottom: 0,
flexDirection: "row",
alignItems: "center",
},
shownText: {
minWidth: 0,
flexShrink: 1,
},
measuring: {
opacity: 0,
},
measurer: {
position: "absolute",
left: 0,
top: 0,
flexDirection: "row",
alignItems: "flex-start",
opacity: 0,
},
measured: {
flexShrink: 0,
},
});
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { resolveTailEllipsis, tailEllipsisCandidates } from "./resolve";
const candidates = tailEllipsisCandidates("Ten second stream");
const candidateWidths = [34, 80];
describe("tail ellipsis", () => {
it("cuts at word boundaries, so no whitespace sits before the ellipsis", () => {
expect(candidates).toEqual(["Ten…", "Ten second…"]);
expect(tailEllipsisCandidates(" GPT-5.1 Codex ")).toEqual(["GPT-5.1…"]);
expect(tailEllipsisCandidates("claude-opus-4-5")).toEqual([]);
});
it("shows the full text while it fits", () => {
expect(
resolveTailEllipsis({
candidates,
measurements: { boxWidth: 132, fullWidth: 132.3, candidateWidths },
}),
).toEqual({ kind: "full" });
});
it("keeps the most words that fit with the ellipsis", () => {
expect(
resolveTailEllipsis({
candidates,
measurements: { boxWidth: 98, fullWidth: 132, candidateWidths },
}),
).toEqual({ kind: "truncated", text: "Ten second…" });
expect(
resolveTailEllipsis({
candidates,
measurements: { boxWidth: 60, fullWidth: 132, candidateWidths },
}),
).toEqual({ kind: "truncated", text: "Ten…" });
});
it("lets the platform cut inside a first word too long for the box", () => {
expect(
resolveTailEllipsis({
candidates,
measurements: { boxWidth: 20, fullWidth: 132, candidateWidths },
}),
).toEqual({ kind: "clipped" });
expect(
resolveTailEllipsis({
candidates: [],
measurements: { boxWidth: 60, fullWidth: 132, candidateWidths: [] },
}),
).toEqual({ kind: "clipped" });
});
it("decides nothing until the widths it needs are measured", () => {
expect(resolveTailEllipsis({ candidates, measurements: { candidateWidths } })).toEqual({
kind: "measuring",
});
expect(
resolveTailEllipsis({
candidates,
measurements: { boxWidth: 98, fullWidth: 132, candidateWidths: [34] },
}),
).toEqual({ kind: "measuring" });
});
});
@@ -0,0 +1,47 @@
const ELLIPSIS = "…";
/**
* The word-boundary truncations of `text`, shortest first: "Ten second stream" gives "Ten…" and
* "Ten second…". Cutting at a word keeps whitespace out from before the ellipsis.
*/
export function tailEllipsisCandidates(text: string): string[] {
const words = text.trim().split(/\s+/);
return words.slice(1).map((_, index) => `${words.slice(0, index + 1).join(" ")}${ELLIPSIS}`);
}
export type TailEllipsis =
| { kind: "measuring" }
| { kind: "full" }
| { kind: "truncated"; text: string }
/** Not even the first word fits: the platform ellipsis cuts inside it, where no space can sit. */
| { kind: "clipped" };
/** Widths in points, measured from the rendered text. */
export interface TailEllipsisMeasurements {
/** The space the text may take, up to its full width. */
boxWidth?: number;
fullWidth?: number;
/** One per `tailEllipsisCandidates` entry, in the same order. */
candidateWidths: readonly (number | undefined)[];
}
/** Sub-point slack for widths the layout rounds differently from the text it measures. */
const ROUNDING_SLACK = 0.5;
export function resolveTailEllipsis(input: {
candidates: readonly string[];
measurements: TailEllipsisMeasurements;
}): TailEllipsis {
const { boxWidth, fullWidth, candidateWidths } = input.measurements;
if (boxWidth === undefined || fullWidth === undefined) return { kind: "measuring" };
if (fullWidth <= boxWidth + ROUNDING_SLACK) return { kind: "full" };
if (input.candidates.some((_, index) => candidateWidths[index] === undefined)) {
return { kind: "measuring" };
}
for (let index = input.candidates.length - 1; index >= 0; index -= 1) {
if ((candidateWidths[index] ?? Infinity) <= boxWidth) {
return { kind: "truncated", text: input.candidates[index] };
}
}
return { kind: "clipped" };
}
@@ -6,9 +6,18 @@ import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import type { Theme } from "@/styles/theme";
export const SCRIM_WIDTH = 48;
const SCRIM_SOLID_OFFSET = "55%";
// The fade is a fixed length so a wider scrim only adds solid surface under its actions.
const SCRIM_FADE_WIDTH = SCRIM_WIDTH * 0.55;
function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; color: string }) {
function TrailingActionScrimSvg({
gradientId,
color,
solidOffset,
}: {
gradientId: string;
color: string;
solidOffset: string;
}) {
return (
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
@@ -16,7 +25,7 @@ function TrailingActionScrimSvg({ gradientId, color }: { gradientId: string; col
{/* Vary opacity rather than interpolating toward `transparent`, which crosses black in
some engines and leaves a grey fringe. */}
<Stop offset="0%" stopColor={color} stopOpacity={0} />
<Stop offset={SCRIM_SOLID_OFFSET} stopColor={color} stopOpacity={1} />
<Stop offset={solidOffset} stopColor={color} stopOpacity={1} />
<Stop offset="100%" stopColor={color} stopOpacity={1} />
</SvgLinearGradient>
</Defs>
@@ -39,17 +48,22 @@ const backdropColorMappings: Record<SurfaceBackdrop, (theme: Theme) => { color:
/** Fades trailing content into the surface beneath an absolutely overlaid action. */
export function TrailingActionScrim({
backdrop,
width = SCRIM_WIDTH,
testID,
}: {
backdrop: SurfaceBackdrop;
/** Covers more than one trailing action; the fade keeps its length. */
width?: number;
testID?: string;
}) {
// React-generated ids contain characters that are invalid inside SVG fragment references.
const gradientId = `trailing-action-scrim-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
const solidOffset = `${Math.round((SCRIM_FADE_WIDTH / width) * 100)}%`;
return (
<View style={styles.scrim} pointerEvents="none" testID={testID}>
<View style={styles.scrim(width)} pointerEvents="none" testID={testID}>
<ThemedTrailingActionScrimSvg
gradientId={gradientId}
solidOffset={solidOffset}
uniProps={backdropColorMappings[backdrop]}
/>
</View>
@@ -57,11 +71,11 @@ export function TrailingActionScrim({
}
const styles = StyleSheet.create({
scrim: {
scrim: (width: number) => ({
position: "absolute",
top: 0,
bottom: 0,
right: 0,
width: SCRIM_WIDTH,
},
width,
}),
});
@@ -2,7 +2,7 @@ import { forwardRef, useCallback, type ComponentProps } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { TOUCH_ICON_SIZE, toolbarContentSlot, toolbarSlot } from "@/components/ui/control-geometry";
import { useComposerControlLayout } from "@/composer/agent-controls/layout-context";
import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph";
import type { AgentControlIcon } from "@/agent-controls/icons";
@@ -42,10 +42,13 @@ export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
},
ref,
) {
const { glyphSize } = useComposerControlLayout();
const { glyphs } = useComposerControlLayout();
const glyphRole = showToolbarLabel ? "label" : "icon";
const glyph = glyphs[glyphRole];
const isSheet = surface === "sheet";
const resolvedGlyphSize = isSheet ? 16 : glyphSize;
const resolvedIconColor = iconColor ?? styles.iconColor.color;
const toolbarIconColor =
glyph.tone === "foreground" ? styles.iconColorForeground.color : styles.iconColor.color;
const resolvedIconColor = iconColor ?? (isSheet ? styles.iconColor.color : toolbarIconColor);
const showValue = isSheet || showToolbarLabel;
const triggerStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType) => [
@@ -73,11 +76,11 @@ export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
>
{isSheet ? (
<View style={styles.sheetGlyph}>
<Icon size={resolvedGlyphSize} color={resolvedIconColor} />
<Icon size={16} color={resolvedIconColor} />
</View>
) : (
<ComposerToolbarGlyph size={resolvedGlyphSize}>
<Icon size={resolvedGlyphSize} color={resolvedIconColor} />
<ComposerToolbarGlyph role={glyphRole}>
<Icon size={glyph.size} strokeWidth={glyph.strokeWidth} color={resolvedIconColor} />
</ComposerToolbarGlyph>
)}
{isSheet ? (
@@ -97,18 +100,17 @@ export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
const styles = StyleSheet.create((theme) => ({
toolbarControl: {
height: TOOLBAR_CONTROL_HEIGHT,
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
...toolbarContentSlot(theme.spacing[2]),
borderRadius: theme.borderRadius.full,
backgroundColor: "transparent",
},
toolbarIconOnly: {
width: TOOLBAR_CONTROL_HEIGHT,
...toolbarSlot(TOUCH_ICON_SIZE.md),
flexShrink: 0,
paddingHorizontal: 0,
justifyContent: "center",
@@ -160,7 +162,7 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
},
pressed: {
backgroundColor: theme.colors.surface0,
backgroundColor: theme.colors.surface2,
},
disabled: {
opacity: 0.5,
@@ -168,4 +170,7 @@ const styles = StyleSheet.create((theme) => ({
iconColor: {
color: theme.colors.foregroundMuted,
},
iconColorForeground: {
color: theme.colors.foreground,
},
}));
@@ -1,10 +1,22 @@
import type { ReactNode } from "react";
import { StyleSheet, View } from "react-native";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { TOOLBAR_GLYPH_SIZES, type ToolbarGlyphs } from "@/components/ui/control-geometry";
export function ComposerToolbarGlyph({ children, size }: { children: ReactNode; size: number }) {
/**
* The slot a composer toolbar control's glyph sits in; see `toolbarGlyphsFor`. `label` when the
* glyph sits beside a label, `icon` when the glyph is the whole control.
*/
export function ComposerToolbarGlyph({
role,
children,
}: {
role: keyof ToolbarGlyphs;
children: ReactNode;
}) {
return (
<View
style={size >= 20 ? styles.touch : styles.pointer}
style={role === "label" ? styles.labelSlot : styles.iconSlot}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
pointerEvents="none"
@@ -14,19 +26,9 @@ export function ComposerToolbarGlyph({ children, size }: { children: ReactNode;
);
}
const slot = { flexShrink: 0, alignItems: "center", justifyContent: "center" } as const;
const styles = StyleSheet.create({
pointer: {
width: 16,
height: 16,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
touch: {
width: 20,
height: 20,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
iconSlot: { ...slot, width: TOOLBAR_GLYPH_SIZES.icon, height: TOOLBAR_GLYPH_SIZES.icon },
labelSlot: { ...slot, width: TOOLBAR_GLYPH_SIZES.label, height: TOOLBAR_GLYPH_SIZES.label },
});
@@ -61,7 +61,16 @@ import {
resolveAgentModelSelection,
} from "@/composer/agent-controls/utils";
import { useIsCompactFormFactor } from "@/constants/layout";
import { controlSizingForLayout, TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import {
controlSizingForLayout,
TOOLBAR_CONTROL_GAP,
bySizing,
toolbarGlyphColor,
type ToolbarGlyphs,
TOUCH_ICON_SIZE,
toolbarContentSlot,
toolbarSlot,
} from "@/components/ui/control-geometry";
import { DensityIcon } from "@/components/ui/density-icon";
import { readMeasuredWidth } from "@/hooks/use-container-width";
import { useToast } from "@/contexts/toast-context";
@@ -79,7 +88,10 @@ import {
type ComposerControlDensity,
type ComposerControlPresentation,
} from "@/composer/agent-controls/layout";
import { ComposerControlLayoutProvider } from "@/composer/agent-controls/layout-context";
import {
ComposerControlLayoutProvider,
useComposerControlLayout,
} from "@/composer/agent-controls/layout-context";
import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph";
import { AgentControlTrigger } from "@/composer/agent-controls/control";
import { CompactModelSheet } from "@/composer/agent-controls/model-sheet";
@@ -219,10 +231,10 @@ function getFeatureIconColor(
green: { 400: string };
yellow: { 400: string };
},
foregroundMuted: string,
restColor: string,
): string {
if (!isFeatureActive(feature)) {
return foregroundMuted;
return restColor;
}
switch (getFeatureHighlightColor(feature.id)) {
@@ -233,7 +245,7 @@ function getFeatureIconColor(
case "yellow":
return palette.yellow[400];
default:
return foregroundMuted;
return restColor;
}
}
@@ -585,7 +597,7 @@ function ControlledAgentControls({
const presentation = useMemo(() => resolveComposerControlPresentation(density), [density]);
const layoutContextValue = useMemo(
() => ({
glyphSize: toolbarGeometry.glyphSize,
glyphs: toolbarGeometry.glyphs,
presentation,
}),
[presentation, toolbarGeometry],
@@ -799,7 +811,7 @@ function ControlledAgentControls({
renderThinkingOption={renderThinkingOption}
modeControl={modeControl}
presentation={presentation}
glyphSize={layoutContextValue.glyphSize}
glyphs={layoutContextValue.glyphs}
activeSheet={activeSheet}
handleOpenSheet={handleOpenSheet}
handleCloseSheet={handleCloseSheet}
@@ -838,7 +850,7 @@ function ControlledAgentControls({
handleOpenChange={handleSheetOpenChange}
renderThinkingOption={renderThinkingOption}
modeControl={modeControl}
glyphSize={layoutContextValue.glyphSize}
glyphs={layoutContextValue.glyphs}
showLabels={presentation.showModelLabel}
modelSelectorServerId={modelSelectorServerId}
canSwitchProvider={Boolean(onSelectProviderAndModel)}
@@ -900,7 +912,7 @@ interface DesktopAgentControlsContentProps {
}) => ReactElement;
modeControl?: AgentModeControlValue | null;
presentation: ComposerControlPresentation;
glyphSize: number;
glyphs: ToolbarGlyphs;
activeSheet: ActiveSheet;
handleOpenSheet: (sheet: Exclude<ActiveSheet, null>) => void;
handleCloseSheet: () => void;
@@ -957,15 +969,15 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
renderThinkingOption,
modeControl,
presentation,
glyphSize,
glyphs,
activeSheet,
handleOpenSheet,
handleCloseSheet,
modelSelectorServerId,
} = props;
const modelToolbar = useMemo(
() => ({ glyphSize, showCaret: presentation.showCarets }),
[glyphSize, presentation.showCarets],
() => ({ glyph: glyphs.label, showCaret: presentation.showCarets }),
[glyphs, presentation.showCarets],
);
const featuresSheetHeader = useMemo<SheetHeader>(
() => ({ title: t("agentControls.features.title") }),
@@ -1086,8 +1098,12 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
accessibilityLabel={t("agentControls.features.open")}
testID="agent-controls-features"
>
<ComposerToolbarGlyph size={glyphSize}>
<Settings2 size={glyphSize} color={theme.colors.foregroundMuted} />
<ComposerToolbarGlyph role="icon">
<Settings2
size={glyphs.icon.size}
strokeWidth={glyphs.icon.strokeWidth}
color={toolbarGlyphColor(theme, glyphs.icon.tone)}
/>
</ComposerToolbarGlyph>
</Pressable>
<AdaptiveModalSheet
@@ -1162,7 +1178,7 @@ interface SheetAgentControlsContentProps {
onPress: () => void;
}) => ReactElement;
modeControl?: AgentModeControlValue | null;
glyphSize: number;
glyphs: ToolbarGlyphs;
showLabels: boolean;
modelSelectorServerId: string | null;
canSwitchProvider: boolean;
@@ -1202,7 +1218,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
handleOpenChange,
renderThinkingOption,
modeControl,
glyphSize,
glyphs,
showLabels,
modelSelectorServerId,
canSwitchProvider,
@@ -1291,7 +1307,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
onRetryProvider={onRetryModelProvider}
isRetryingProvider={isRetryingModelProvider}
serverId={modelSelectorServerId}
glyphSize={glyphSize}
glyphs={glyphs}
showLabels={showLabels}
canSwitchProvider={canSwitchProvider}
>
@@ -1316,6 +1332,8 @@ function DesktopFeatureItem({
onActionComplete?: () => void;
}) {
const { theme } = useUnistyles();
const { glyphs } = useComposerControlLayout();
const restColor = toolbarGlyphColor(theme, glyphs.icon.tone);
const featureSelector: AgentControlSelector = `feature-${feature.id}`;
const featureAnchorRef = useRef<View>(null);
@@ -1356,11 +1374,7 @@ function DesktopFeatureItem({
<TooltipTrigger asChild triggerRefProp="ref">
<AgentControlTrigger
icon={FeatureIcon}
iconColor={getFeatureIconColor(
feature,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
iconColor={getFeatureIconColor(feature, theme.colors.palette, restColor)}
surface="toolbar"
label={feature.label}
showToolbarLabel={false}
@@ -1391,11 +1405,7 @@ function DesktopFeatureItem({
<AgentControlTrigger
ref={featureAnchorRef}
icon={FeatureIcon}
iconColor={getFeatureIconColor(
feature,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
iconColor={getFeatureIconColor(feature, theme.colors.palette, restColor)}
surface="toolbar"
label={feature.label}
value={selectedOption?.label ?? feature.label}
@@ -1972,17 +1982,20 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
gap: TOOLBAR_CONTROL_GAP,
// Clips pickers that run out of room. Touch-sized the pickers' pills and circles reach 8 past
// what the row spaces, so the clip reaches as far.
overflow: "hidden",
paddingHorizontal: bySizing({ touch: theme.spacing[2], pointer: 0 }),
marginHorizontal: bySizing({ touch: -theme.spacing[2], pointer: 0 }),
},
modeBadge: {
height: TOOLBAR_CONTROL_HEIGHT,
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"],
...toolbarContentSlot(theme.spacing[2]),
borderRadius: theme.borderRadius.full,
},
modelControl: {
minWidth: 0,
@@ -1994,8 +2007,7 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0,
},
modeIconBadge: {
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
...toolbarSlot(TOUCH_ICON_SIZE.md),
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 0,
@@ -2007,7 +2019,7 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
},
modeBadgePressed: {
backgroundColor: theme.colors.surface0,
backgroundColor: theme.colors.surface2,
},
disabledBadge: {
opacity: 0.5,
@@ -1,16 +1,17 @@
import { createContext, useContext, type ReactNode } from "react";
import { toolbarGlyphsFor, type ToolbarGlyphs } from "@/components/ui/control-geometry";
import {
resolveComposerControlPresentation,
type ComposerControlPresentation,
} from "@/composer/agent-controls/layout";
interface ComposerControlLayoutValue {
glyphSize: number;
glyphs: ToolbarGlyphs;
presentation: ComposerControlPresentation;
}
const DEFAULT_LAYOUT: ComposerControlLayoutValue = {
glyphSize: 16,
glyphs: toolbarGlyphsFor("pointer"),
presentation: resolveComposerControlPresentation("full"),
};
@@ -3,6 +3,7 @@ import {
composerToolbarGeometry,
resolveComposerControlDensity,
resolveComposerControlPresentation,
resolveModelTriggerLabels,
} from "./layout";
const POINTER = composerToolbarGeometry("pointer");
@@ -174,21 +175,29 @@ describe("composer control layout", () => {
).toBe("condensed");
});
it("gives every toolbar control one shell and one glyph per control sizing", () => {
it("gives every toolbar control one shell and one glyph per role and control sizing", () => {
const pointerGlyph = { size: 16, strokeWidth: 1.5, tone: "muted" };
expect(POINTER).toEqual({
controlSize: 28,
glyphSize: 16,
glyphs: { icon: pointerGlyph, label: pointerGlyph },
controlGap: 4,
iconLabelGap: 4,
labelPadding: 8,
caretSize: 14,
});
expect(TOUCH).toMatchObject({ controlSize: 44, glyphSize: 20 });
expect(TOUCH).toMatchObject({
controlSize: 20,
glyphs: {
icon: { size: 20, strokeWidth: 1.5, tone: "foreground" },
label: { size: 20, strokeWidth: 1.5, tone: "foreground" },
},
controlGap: 20,
});
});
it("budgets touch-sized controls, so a touch toolbar condenses at a width a pointer one fits", () => {
const input = {
availableWidth: 420,
availableWidth: 400,
currentDensity: "full" as const,
controls: {
hasModel: true,
@@ -204,8 +213,8 @@ describe("composer control layout", () => {
});
describe("touch toolbar on an iPad in portrait, sidebar docked", () => {
// Codex: model, thinking, mode and two toggle features. The measured toolbar is 272 wide
// with an empty draft and 224 once an attachment brings in the send button.
// Codex: model, thinking, mode and two toggle features. Touch controls lay out at what they
// paint, 20pt apart, so icon-only thinking, mode and features need 200.
const controls = {
hasModel: true,
hasThinking: true,
@@ -217,7 +226,7 @@ describe("composer control layout", () => {
it("keeps the model family label with icon-only controls on an empty draft", () => {
expect(
resolveComposerControlDensity({
availableWidth: 272,
availableWidth: 280,
currentDensity: "full",
geometry: TOUCH,
controls,
@@ -225,10 +234,21 @@ describe("composer control layout", () => {
).toBe("tight");
});
it("folds thinking, mode and features into the model control once an attachment narrows it", () => {
it("keeps the icon-only controls once an attachment brings in send", () => {
expect(
resolveComposerControlDensity({
availableWidth: 224,
availableWidth: 240,
currentDensity: "tight",
geometry: TOUCH,
controls,
}),
).toBe("tight");
});
it("folds thinking, mode and features into the model control below their width", () => {
expect(
resolveComposerControlDensity({
availableWidth: 180,
currentDensity: "tight",
geometry: TOUCH,
controls,
@@ -239,7 +259,7 @@ describe("composer control layout", () => {
it("drops the model label only after the controls are folded", () => {
expect(
resolveComposerControlDensity({
availableWidth: 120,
availableWidth: 64,
currentDensity: "compact",
geometry: TOUCH,
controls,
@@ -263,8 +283,58 @@ describe("composer control layout", () => {
};
expect(resolveComposerControlDensity({ ...input, availableWidth: 600 })).toBe("compact");
// The iPhone toolbar with an attachment: "GPT-6…" beside "Medium" still fits.
expect(resolveComposerControlDensity({ ...input, availableWidth: 159 })).toBe("compact");
expect(resolveComposerControlDensity({ ...input, availableWidth: 120 })).toBe("iconOnly");
// The thinking label is not budgeted: it gives way before the model family label does.
expect(resolveComposerControlDensity({ ...input, availableWidth: 96 })).toBe("compact");
expect(resolveComposerControlDensity({ ...input, availableWidth: 64 })).toBe("iconOnly");
});
});
describe("folded model control labels", () => {
it("shows the thinking label while the model label fits beside it in full", () => {
expect(
resolveModelTriggerLabels({
hasThinking: true,
measurements: { availableWidth: 191, fullWidth: 190 },
}),
).toBe("modelAndThinking");
});
it("hides the thinking label when rounded widths tie", () => {
// On the web, 203.14 of labels and a 203 frame both report 203.
expect(
resolveModelTriggerLabels({
hasThinking: true,
measurements: { availableWidth: 203, fullWidth: 203 },
}),
).toBe("model");
});
it("hides the thinking label before the model label would truncate", () => {
expect(
resolveModelTriggerLabels({
hasThinking: true,
measurements: { availableWidth: 189.5, fullWidth: 190 },
}),
).toBe("model");
expect(
resolveModelTriggerLabels({
hasThinking: true,
measurements: { availableWidth: 90, fullWidth: 190 },
}),
).toBe("model");
});
it("decides nothing until both widths are measured", () => {
expect(resolveModelTriggerLabels({ hasThinking: true, measurements: {} })).toBe("measuring");
expect(
resolveModelTriggerLabels({ hasThinking: true, measurements: { availableWidth: 300 } }),
).toBe("measuring");
expect(resolveModelTriggerLabels({ hasThinking: true, measurements: { fullWidth: 120 } })).toBe(
"measuring",
);
});
it("needs no measurement without a thinking label", () => {
expect(resolveModelTriggerLabels({ hasThinking: false, measurements: {} })).toBe("model");
});
});
@@ -1,7 +1,9 @@
import {
iconSizeFor,
TOOLBAR_CONTROL_GAPS,
TOOLBAR_CONTROL_SIZES,
toolbarGlyphsFor,
type ControlSizing,
type ToolbarGlyphs,
} from "@/components/ui/control-geometry";
/**
@@ -39,23 +41,32 @@ export interface ComposerControlPresentation {
showModelLabel: boolean;
}
/** What each toolbar control takes up in the row, which is what the density budget sums. */
export interface ComposerToolbarGeometry {
/** An icon-only control's width in the row. */
controlSize: number;
glyphSize: number;
glyphs: ToolbarGlyphs;
controlGap: number;
iconLabelGap: number;
/** A labelled control's padding on each side, as far as it takes room in the row. */
labelPadding: number;
caretSize: number;
}
/** The toolbar's measurements under a control sizing; see `resolveControlSizing`. */
/**
* The toolbar's measurements under a control sizing; see `resolveControlSizing`. Touch-sized a
* control lays out at the width of what it paints (`toolbarSlot`, `toolbarContentSlot`), so an
* icon-only control takes its glyph's width and a picker's padding takes no room.
*/
export function composerToolbarGeometry(sizing: ControlSizing): ComposerToolbarGeometry {
const glyphs = toolbarGlyphsFor(sizing);
const isTouch = sizing === "touch";
return {
controlSize: TOOLBAR_CONTROL_SIZES[sizing],
glyphSize: iconSizeFor("md")[sizing],
controlGap: 4,
controlSize: isTouch ? glyphs.icon.size : TOOLBAR_CONTROL_SIZES.pointer,
glyphs,
controlGap: TOOLBAR_CONTROL_GAPS[sizing],
iconLabelGap: 4,
labelPadding: 8,
labelPadding: isTouch ? 0 : 8,
caretSize: 14,
};
}
@@ -63,8 +74,6 @@ export function composerToolbarGeometry(sizing: ControlSizing): ComposerToolbarG
const DENSITY_HYSTERESIS = 12;
/** The model label's narrowest useful width: enough for the model family, such as "GPT-6…". */
const MODEL_FAMILY_LABEL_WIDTH = 56;
/** A thinking label beside the folded model label, such as "Medium". */
const THINKING_LABEL_WIDTH = 48;
function normalizedFontScale(fontScale: number): number {
return Number.isFinite(fontScale) ? Math.max(1, fontScale) : 1;
@@ -83,7 +92,7 @@ function estimateLabelWidth(label: string, fontScale: number): number {
/** A labelled control's fixed chrome: its padding, its glyph, and the gap to its label. */
function labelledChrome(geometry: ComposerToolbarGeometry): number {
return geometry.labelPadding * 2 + geometry.glyphSize + geometry.iconLabelGap;
return geometry.labelPadding * 2 + geometry.glyphs.label.size + geometry.iconLabelGap;
}
function resolveFeatureControlWidth(
@@ -147,16 +156,16 @@ function resolveTightFloor(
return sumControlWidths(widths, geometry);
}
/** One model control carrying the model family label and the thinking label. */
/**
* One model control carrying the model family label. Its thinking label is not budgeted: it is
* the first label to go when the control runs out of room; see `resolveModelTriggerLabels`.
*/
function resolveCompactFloor(
controls: ComposerControlPresence,
geometry: ComposerToolbarGeometry,
): number {
const fontScale = normalizedFontScale(controls.fontScale);
const thinkingLabel = controls.hasThinking
? geometry.iconLabelGap + THINKING_LABEL_WIDTH * fontScale
: 0;
return labelledChrome(geometry) + MODEL_FAMILY_LABEL_WIDTH * fontScale + thinkingLabel;
return labelledChrome(geometry) + MODEL_FAMILY_LABEL_WIDTH * fontScale;
}
function resolveDensityFloor(
@@ -251,3 +260,33 @@ export function resolveComposerControlPresentation(
): ComposerControlPresentation {
return PRESENTATIONS[density];
}
/**
* Which labels the folded model control shows. `measuring` until every width is known, so the
* control never paints a choice made without them.
*/
export type ModelTriggerLabels = "measuring" | "modelAndThinking" | "model";
/** Widths measured from the rendered model control, in points. */
export interface ModelTriggerMeasurements {
/** The width the control may take. */
availableWidth: number;
/** The control with its model and thinking labels side by side, untruncated. */
fullWidth: number;
}
/**
* The thinking label goes first: it shows only while the model label fits beside it in full, so
* the model label truncates only once the thinking label is gone. The web reports layout widths
* rounded to whole pixels, so equal widths can hide a fraction of a pixel of overflow; only a
* full width strictly below the available width is known to fit.
*/
export function resolveModelTriggerLabels(input: {
hasThinking: boolean;
measurements: Partial<ModelTriggerMeasurements>;
}): ModelTriggerLabels {
if (!input.hasThinking) return "model";
const { availableWidth, fullWidth } = input.measurements;
if (availableWidth === undefined || fullWidth === undefined) return "measuring";
return fullWidth < availableWidth ? "modelAndThinking" : "model";
}

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