Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 70e475f9b7 docs(plugins): trim publishing guidance to author tasks 2026-10-15 11:08:56 +02:00
Mohamed Boudra 1c990715b2 docs(plugins): explain registry publishing requirements 2026-10-15 10:34:40 +02:00
Mohamed Boudra a96c892302 fix(website): start the sponsors band heart at the column edge (#6993) 2026-10-15 15:04:10 +07:00
Mohamed Boudra 74282dac12 fix(app): return to chat after fullscreen submission (#6987) 2026-10-15 14:55:19 +07:00
Mohamed Boudra 12066431ad feat(website): head the sponsors band with a heart and one Sponsors title (#6988) 2026-10-15 14:36:12 +07:00
paseo-bot[bot] 4d8bed9ddd Keep Claude's context meter on the active model (#6814)
* fix(claude): keep context meter on the active model

Claude result modelUsage accumulates models across the session. Reading its largest window replaced Haiku capacity with a previous Opus window at turn completion. Select the runtime model entry and retain the seeded limit when that entry is absent or invalid.

* refactor(claude): name context fixture inputs and simplify result usage

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-15 09:13:13 +02:00
paseo-bot[bot]andhadan8977 b2ca7dcf03 Check generated IDs in the daemon upload test (#6810)
* test(server): stop expecting request-derived upload IDs

* test(server): check full uuid shape for upload id

* test(server): format generated upload ID assertion

Retain the contributor correction to the stale request-derived ID expectation while satisfying the repository formatter.

Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn>

---------

Co-authored-by: hadan8977 <zc-huang23@mails.tsinghua.edu.cn>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-15 08:08:40 +02:00
Mohamed Boudra 8b1ba3ca58 Lighter landing sections, FAQ last, followed sponsor links (#6970)
* feat(website): follow sponsor links and lay landing sections on the page background

* feat(website): keep landing card copy to two lines
2026-10-15 12:52:09 +07:00
Mohamed BoudraandClaude e5124b039a Put sheets on one stack and show an agent's facts in its sheet (#6958)
* Put sheet tabs on the one sheet stack

Sheets are one primitive with one stack. A sheet opened from any sheet
stacks on top of it, and closing it uncovers the sheet underneath. The
compact workspace's file and subagent sheets were an in-tree Gorhom
BottomSheet with their own stacking, so a subagent chosen from the
subagents list sheet replaced the list instead of covering it.

- SheetHostScope: a subtree hosts the sheets presented inside it, so the
  sheet tabs draw inside the compact column and read the workspace
  providers while still joining the app-wide stack. Needs Gorhom's
  BottomSheetModalInternalProvider, which the patch exports.
- IsolatedBottomSheetModal takes enableDismissOnClose; false keeps the
  content mounted through a close so the unsaved-changes guard can veto
  it. Its backdrop then closes the sheet rather than dismissing the top
  modal. The Gorhom patch also keeps a dismissing modal's status through
  its final onChange(-1), without which such a modal unmounting while
  presented never left the portal.
- SheetTabs (was WorkspaceSheetHost) renders one IsolatedBottomSheetModal
  per store entry. The store never reorders sheets; a reveal of a covered
  sheet drops the unmodified sheets above it.
- Subagent rows that open a sheet keep the list open (closeOnSelect
  false); rows that open a tab still close it.
- A menu closes when its retained panel goes inactive, instead of hiding
  and reappearing with the panel on the next tab switch.

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

* Show an agent's model and thinking in its sheet, and fit Open as tab in the header rail

A Paseo agent shown in a sheet has no composer, so the facts the composer's
model and thinking controls would show now sit in one line under the sheet
header, the same row provider subagents already get from the daemon. The line
resolves exactly as the controls do (resolveAgentModelSelection), from the
session agent and the providers snapshot, and shows wherever the pane host is
a sheet.

Open as tab becomes the full-screen glyph beside Close, drawn by the shared
SheetHeaderIconButton, so the title keeps its width. The tab switcher sheet is
titled "Workspace tabs" so it reads as what it lists.

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

* Show mode and context usage in the sheet facts line

The line under a sheet's header now carries everything the composer's
controls and context meter show: model, thinking, mode, and used/max context
tokens with the share, each resolved as its control resolves it.

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

* Re-render subagent rows when the workspace's tabs change

A row decides at render time whether choosing it opens a sheet or reveals
a tab, and keeps the list open only for a sheet. The hook behind it only
subscribed to the layout mode, so a row rendered while its agent was a
full tab kept closing the list after that tab was gone: on iOS, promoting
a managed child, closing its tab, and reopening it from the list dropped
the list under the sheet. Subscribe to the workspace's layout too.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-15 12:16:45 +07:00
paseo-bot[bot] 5c108f345c Preserve renames in committed file diffs (#6895)
* fix(git): preserve renames in committed file diffs

Follow the requested destination through rename detection so the patch agrees with commit history instead of showing the whole file as newly added.

* test(git): keep rename fixtures valid across platforms

* fix(git): keep added copies out of committed rename diffs

Detect rename sources across the first-parent commit before narrowing the patch. Follow mode also detected copies, causing added files to open as partial diffs against unchanged files.

* perf(git): share committed rename discovery across file requests

Opening a commit requests all of its file diffs concurrently. Share pending and completed rename scans for immutable commit IDs in a bounded cache, while symbolic refs always read fresh metadata.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-15 07:05:42 +02:00
Mohamed Boudra 9ab99a6e1e feat(website): show sponsors on paseo.sh and at the top of the README (#6956) 2026-10-15 11:19:32 +07:00
paseo-bot[bot] 8ff2424da1 Return checkout diff hunks from subdirectory workspaces (#6882)
* fix(git): return checkout diff hunks from subdirectory workspaces

Checkout paths are repository-relative, but one-shot diff reads used the workspace subdirectory for Git pathspecs. Resolve the worktree root once so patch selection and file reads use the same coordinates as the live subscription.

* test(server): await bounded discard fixture cleanup on Windows

The discard assertions pass but Windows can retain a directory lock during cleanup. Await asynchronous removal with bounded retries after daemon shutdown, preserving failure for persistent locks. Matches the verified cleanup correction in #6912 for #6918.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-15 05:08:39 +02:00
Mohamed Boudra 6db47b3c37 fix(app): keep the keyboard closed after mobile sidebar dismissal (#6945)
Separate composer keyboard eligibility from pane focus restoration. A sidebar dismisses the keyboard without hiding the pane, so its close must not restore editing focus.
2026-10-15 08:59:49 +07:00
paseo-bot[bot] 73221ce5bd fix(app): render local Markdown images with encoded paths (#6877)
Decode assistant Markdown image destinations before resolving a filesystem read. Keep plain filesystem paths and direct image URIs unchanged so Markdown Preview and literal percent filenames are decoded only once.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-15 03:14:42 +02:00
Mohamed Boudra 6ad124758c Stack workspace sheets and tighten sidebar density (#6885)
* Stack workspace sheets and tighten sidebar density

- Workspace sheets stack: opening a target from a sheet covers it, Back and
  drag close only the top sheet, and the header is the sheet's drag handle.
- The workspace sheet reuses the shared sheet handle, background and header.
- Fix the shared sheet handle indicator rendering black.
- Sidebar text returns to semantic sizes; Pinned and Workspaces share one
  section header.

* Pause covered workspace sheets and mock the shared sheet handle in tests

* Export BottomSheetHandle from the browser test Gorhom stub

* Let a stacked sheet's backdrop catch taps on iOS

* Bring a revealed sheet to the top instead of duplicating it as a tab
2026-10-15 06:30:17 +07:00
Mohamed Boudra f3ddf99fe3 fix(app): restore fullscreen composer keyboard motion and clearing (#6902)
* fix(app): keep fullscreen composer in the keyboard dock

* fix(app): preserve composer caret and scope keyboard actions
2026-10-15 06:30:13 +07:00
paseo-bot[bot]andKiraPC 404d08d24b Report failed terminal launches and remove early exits (#6792)
* fix(server): report a terminal whose command cannot start instead of leaving a dead tab

Opening a terminal profile whose command is not installed left a tab that could
never be closed. Two problems sat on the same path.

A terminal that dies during creation was resurrected by its own create. The
worker sends terminalCreated, the createTerminal response and terminalExit in
the same tick, so the parent drains all three before the awaiting create
continuation resumes; that continuation called registerRecord a second time and
put back a terminal the exit had just removed. Removal is owned solely by the
terminalExit event and that event never comes twice, so the record outlived
every close attempt: getTerminals kept listing it, capture returned nothing,
kill was a silent no-op that still reported success, and only a daemon restart
cleared it.

The parent now tracks the ids with a create in flight and keeps the finished
record when an exit removes one of them, so the create continuation returns the
exited session instead of registering a live one. Removal still happens exactly
once, in the terminalExit path.

With the stuck tab gone, a missing command produced nothing at all: no tab and
no message. node-pty spawns directly through execve on POSIX, with no shell, so
a command that does not exist fails inside the spawn helper and surfaces as exit
code 1 with no output -- exactly what `false` produces, so the exit carries no
usable signal. createTerminal now resolves the command before spawning and fails
the create when it cannot be found, so the daemon answers create_terminal with a
null terminal and "<command>: command not found", which the app already renders.
A command that starts and then exits non-zero still opens its tab.

That resolution has to match what execve will do in the child, or a working
profile is refused: it uses the terminal's own environment, built first so the
check sees the per-cwd registered PATH and the entries buildTerminalEnvironment
prepends, and it resolves a command containing a separator against the
terminal's cwd rather than through PATH. Windows keeps resolving inside
resolveTerminalSpawnCommand, which has its own shim handling and fallback.

* fix(terminal): check exact executable names and verify launch output

---------

Co-authored-by: KiraPC <pasqualecarmine.carbone@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 23:02:39 +02:00
paseo-bot[bot]and见安 6d43a8c5b0 Restore selected changes from subdirectory workspaces (#6777)
* fix(git): discard repository paths from the worktree root

* test(git): cover discard from nested workspaces through daemon RPC

* test(git): close the daemon before removing discard fixtures

Windows CI reaches the discard assertions but cannot remove the observed
repository while the test daemon is alive. Run fixture removal after the
daemon's afterEach cleanup, preserving all behavioral assertions.

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 20:02:03 +02:00
paseo-bot[bot] e1a5664d9b fix(app): keep malformed font settings from breaking the UI (#6695)
Unfinished CSS strings in font preferences consumed the shared theme stylesheet. Reject incomplete syntax at the settings boundary, recover persisted malformed values through the existing default path, and restore rejected edits in the font field.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 19:17:06 +02:00
paseo-bot[bot] b58af43a06 fix(app): render mixed HTML headings without literal tags (#6650)
Keep a matched heading together before splitting its image and text contents. Splitting first stranded the opening heading tag in a text slice without its closing tag.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 18:14:08 +02:00
truelecter 1bf3a57a1c fix(nix): raise Node heap for the desktop build (#6537)
Metro's transform worker runs out of heap on the generated ws-outbound.aot.js validator on machines where Node's default heap is 2 GB, such as the 7 GB macOS runner of the Nix workflow. Set NODE_OPTIONS=--max-old-space-size=4096 in the desktop derivation, as desktop-release.yml already does.

Closes #6535
2026-10-14 22:32:26 +07:00
56448842f0 Download files over the connected host (#6595)
* Let readFile callers disable the request timeout

readFile resolves only when FileEnd arrives, so the default 60 s request
timeout bounds the whole transfer. A large file over a slow relay fails
before it finishes even though every chunk is arriving. Thread an optional
timeout through requestFileExplorer; 0 disables the timer, as the
workspace.create request already does, and a dead connection still rejects
on disconnect.

The relay e2e case reads a 1 MiB file through a real daemon and a local
relay with timeout 0 and compares SHA-256 digests with the source.

* Reject a binary file transfer whose byte count differs from FileBegin.size

concatByteChunks allocated FileBegin.size bytes and copied the chunks in:
a short transfer resolved as a zero-padded file, and a long one threw a
RangeError inside the transport callback and left the request pending.
Count the received bytes at FileEnd and reject the read when they differ
from the announced size.

* Download files over the session socket when no direct connection is active

The download store only knew the HTTP token route and picked the first
directTcp connection in the host profile, whatever connection the host
runtime was using. A relay-only host has none, so every download ended in
"Download host is unavailable." (#543). SSH, socket and pipe hosts failed
the same way, and a stale directTcp entry won over a working relay (#3753).

Choose the transport from the active connection: directTcp keeps the HTTP
token download unchanged; anything else reads the file over the session
socket with the binary frames previews already use, then hands the bytes
to the browser's download manager on web and Electron or writes them to
the cache directory and offers the share sheet on native. Files over
64 MiB are refused before any byte moves, using the existing fileTooLarge
string, and a daemon response without content fails instead of saving an
empty file.

* fix(app): enforce the session download size limit during the read

The token size is sampled before the file is read. Pass the download byte budget through the actual read so a changed file cannot bypass the in-memory limit.

Co-authored-by: Michael Vandesompel <mv@droggol.be>
Co-authored-by: YASICJUNWOO <joonoo3@naver.com>
Co-authored-by: JrDw0 <JrDw0@users.noreply.github.com>

* test: verify TCP downloads and own relay fixture cleanup

---------

Co-authored-by: Michael Vandesompel <mv@droggol.be>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: YASICJUNWOO <joonoo3@naver.com>
Co-authored-by: JrDw0 <JrDw0@users.noreply.github.com>
2026-10-14 17:25:31 +02:00
paseo-bot[bot] 14e6384516 fix(desktop): keep running when launcher console pipes close (#6639)
Desktop logging reaches inherited stdout and stderr before the CLI guards run. A launcher that closes its output reader raised an uncaught EPIPE dialog. Handle closed console pipes at desktop startup while preserving console logging and surfacing other stream errors.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 15:27:12 +02:00
paseo-bot[bot]andRaisal P Wardana cafa04d3fb fix(app): traverse workspaces beyond nine (#6586)
Relative navigation shared the list capped for digit shortcuts, so it wrapped at the ninth workspace. Keep all sidebar targets and cap only numbered badges. Carry forward #4128 with keyboard-routing and browser regressions.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Raisal P Wardana <IEatCodeDaily@users.noreply.github.com>
2026-10-14 13:14:08 +02:00
paseo-bot[bot]and见安 938c4e2632 Show Git diffs for filenames with quotes and control characters (#6583)
* fix(diff): decode quoted Git patch paths

* fix(server): preserve Git paths in diff and commit listings

* refactor(diff): make quoted-path conversions explicit

* test(server): verify quoted filename diffs through daemon RPC

Keep the checkout and commit-file regression on the public client interface, and align the large-file command assertion with NUL-delimited statistics.

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>

* test(server): run filename RPC regression in integration checks

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 12:09:47 +02:00
paseo-bot[bot] d57096a456 Start newly configured workspace services without restarting (#6542)
* fix(server): start newly configured workspace services without restarting

Extend the workspace port plan when paseo.json declares another service, preserving existing peer ports. Serialize extensions and roll back only their new reservations on failure.

* fix(server): serialize service restarts with port plan extensions

Keep concurrent restart and newly added service allocations in the same workspace update transaction so neither overwrites the other.

* fix(server): keep service port update failures scoped and release cancelled reservations

* test(hub): show creation errors in re-enrollment failures

Assert the error field along with success so failed CI reports include the reason agent creation was rejected.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 11:07:06 +02:00
paseo-bot[bot] ada7ba43f0 Keep long custom models removable in provider settings (#6507)
* fix(app): keep long custom models removable

Custom model labels and IDs could consume the row width and push Remove outside the sheet. Allow those text cells to shrink while preserving the button width and short-name alignment.

* test(app): wait for legacy agent creation before checking prompts

Agent-list membership includes initialization. Waiting for finish at that point can return on the initial idle transition before the initial prompt starts. Wait for the matching creation response before checking each completed turn, preserving the existing exact-once assertions.

* test(app): wait for directory search keyboard focus

The search page becomes visible before its deferred focus callback runs. Wait for the input to own keyboard focus so the journey types its query rather than dropping it during navigation.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 10:21:52 +02:00
165 changed files with 7272 additions and 1938 deletions
+13 -4
View File
@@ -53,6 +53,17 @@
- **クロスデバイス:** iOS、Android、デスクトップ、Web、CLI に対応。机で作業を始め、スマートフォンで確認し、ターミナルから自動化できます。
- **プライバシー優先:** Paseo にはテレメトリー・トラッキング・強制ログインは一切ありません。
## スポンサー
<!-- Sponsors: one cell per sponsor in the order of packages/website/src/data/sponsors.ts, then "become a sponsor" cells up to three. -->
<table>
<tr>
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Paseo のスポンサーになる</a></td>
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Paseo のスポンサーになる</a></td>
</tr>
</table>
## はじめかた
Paseo はコーディングエージェントを管理するローカルサーバー(デーモン)を起動します。デスクトップアプリ・モバイルアプリ・Web アプリ・CLI などのクライアントがこのデーモンに接続します。
@@ -151,11 +162,9 @@ npm run build:server
npm run typecheck
```
## スポンサー
## Paseo を支援する
Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをここと paseo.sh のホームページに掲載します。
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをこの README の冒頭と、paseo.sh のトップページおよびドキュメントに掲載します。
## 関連プロジェクト
+13 -4
View File
@@ -47,6 +47,17 @@
- **여러 기기 지원:** iOS, Android, 데스크톱, 웹, CLI를 지원합니다. 데스크톱에서 시작해 휴대폰으로 확인하고 터미널에서 자동화할 수 있습니다.
- **개인정보 보호 우선:** Paseo는 텔레메트리, 추적, 강제 로그인을 사용하지 않습니다.
## 스폰서
<!-- Sponsors: one cell per sponsor in the order of packages/website/src/data/sponsors.ts, then "become a sponsor" cells up to three. -->
<table>
<tr>
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Paseo 스폰서 되기</a></td>
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Paseo 스폰서 되기</a></td>
</tr>
</table>
## 시작하기
Paseo는 코딩 에이전트를 관리하는 로컬 서버인 데몬을 실행합니다. 데스크톱 앱, 모바일 앱, 웹 앱, CLI 같은 클라이언트가 이 데몬에 연결합니다.
@@ -161,11 +172,9 @@ npm run build:server
npm run typecheck
```
## 스폰서
## Paseo 후원하기
Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이곳과 paseo.sh 홈페이지에 게재할 수 있습니다.
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이 README 상단과 paseo.sh 홈페이지 및 문서에 게재할 수 있습니다.
## 관련 프로젝트
+13 -4
View File
@@ -52,6 +52,17 @@ Paseo is an open source agentic development environment for desktop, mobile, web
[Run parallel tasks in Paseo](https://paseo.sh/docs/parallel-development): start agents in separate worktrees, review their diffs, run each app, and check it in the built-in browser.
## Sponsors
<!-- Sponsors: one cell per sponsor in the order of packages/website/src/data/sponsors.ts, then "become a sponsor" cells up to three. -->
<table>
<tr>
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Become a sponsor</a></td>
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">Become a sponsor</a></td>
</tr>
</table>
## Plugins
Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web,
@@ -207,11 +218,9 @@ npm run build:server
npm run typecheck
```
## Sponsors
## Support Paseo
Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage.
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown at the top of this README and on the paseo.sh homepage and docs.
## Related projects
+13 -4
View File
@@ -53,6 +53,17 @@
- **跨设备:** 支持 iOS、Android、桌面端、Web 和 CLI。在桌前开始工作,用手机查看进度,也可以从终端脚本化操作。
- **隐私优先:** Paseo 没有遥测、追踪,也不会强制登录。
## 赞助商
<!-- Sponsors: one cell per sponsor in the order of packages/website/src/data/sponsors.ts, then "become a sponsor" cells up to three. -->
<table>
<tr>
<td align="center" width="33%"><a href="https://hoplite.sh/?utm_source=paseo&utm_medium=readme&utm_campaign=sponsor"><picture><source media="(prefers-color-scheme: dark)" srcset="packages/website/public/sponsors/hoplite-on-dark.svg"><img src="packages/website/public/sponsors/hoplite-on-light.svg" alt="Hoplite" height="40"></picture></a><br><sub>Cloud coding agents</sub></td>
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">成为 Paseo 赞助商</a></td>
<td align="center" width="33%"><a href="https://paseo.sh/sponsor">成为 Paseo 赞助商</a></td>
</tr>
</table>
## 快速开始
Paseo 会运行一个名为 daemon 的本地服务,用来管理你的 coding agents。桌面 app、移动 app、Web app 和 CLI 等客户端都会连接到它。
@@ -151,11 +162,9 @@ npm run build:server
npm run typecheck
```
## 赞助
## 支持 Paseo
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在这里和 paseo.sh 首页上。
<!-- Sponsor logos go here, in the same order as packages/website/src/data/sponsors.ts -->
Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。
## 相关项目
+1 -1
View File
@@ -212,7 +212,7 @@ parentAgentId === thisAgent.id AND !archivedAt
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
Clicking either kind opens a workspace tab. On compact layouts it opens in the [workspace sheet](explorer-sidebar.md#workspace-sheet) instead; the sheet hides the composer, and a Paseo subagent's sheet offers **Open as tab** to get it back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
+22 -10
View File
@@ -7,7 +7,7 @@ contracts.
| ---------------- | ---------------------------- | ------------------------------------------ |
| Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock |
| Side pane | Ordinary workspace content | Created and closed like any workspace pane |
| Workspace sheet | Compact look-ups | Holds one tab; closes or becomes a tab |
| Sheets | Compact look-ups | One tab each, on the app-wide sheet stack |
## Panel host contract
@@ -23,18 +23,30 @@ can move between Explorer and main panes.
Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and
retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts.
## Workspace sheet
## Sheets
On compact layouts, files and subagents open in a closable sheet over the workspace, so the tab
switcher keeps only agents and terminals. A panel kind opts in with `compactSheet` in its manifest
On compact layouts, files and subagents open in a sheet over the workspace, so the tab switcher
keeps only agents and terminals. A panel kind opts in with `compactSheet` in its manifest
(`packages/app/src/panels/panel-manifest.ts`), and the layout store's `openTab` decides between
sheet and tab, so callers open targets the same way on every layout. The sheet holds one ordinary
`WorkspaceTab`, which is not persisted.
sheet and tab, so callers open targets the same way on every layout. Each such sheet holds one
ordinary `WorkspaceTab` (`SheetTab` in the store) and is an `IsolatedBottomSheetModal` like every
other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet, a
subagent chosen from the subagents list included, lands on top of it, and closing the top sheet
(drag, Close, backdrop, or Android Back) uncovers the one below. The list stays open under the
sheet it opened; see [menus.md](menus.md#selecting-an-item-on-ios) for the row contract.
The sheet is modal and covers the whole compact column, header included. It renders in the
workspace tree rather than through `IsolatedBottomSheetModal`: its panel needs the navigator and
every workspace provider an ordinary pane has, and a portal drops them
([floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site)).
The store keeps `sheetsByWorkspace` in opening order and never reorders it, because moving a sheet
remounts its panel. Revealing a target that already has a sheet uncovers that sheet by closing the
ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab
dismisses the sheets above it, since the tab would sit under them. The stack is not persisted.
Sheets are modal and cover the whole compact column, header included. A sheet's header is its drag
handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail
holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside
Close, so the title keeps its width. The sheets present
from inside the compact column's `SheetHostScope`, so their panels read the navigator and every
workspace provider an ordinary pane has without a context bridge
([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)).
A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it.
Moving its tab to another host remounts the panel, and the file editor keeps unsaved text in the
+59 -8
View File
@@ -198,6 +198,47 @@ viewport keeps the last keyboard reservation until the next opening replaces
it, so a capped draft keeps one height through every keyboard transition
instead of expanding on close and shrinking again on open.
Fullscreen is a layout mode of the dock, not a second editor. `ComposerDock
fullscreen` fills the stationary viewport with the composer slot over the
still-mounted content, paints its own surface under the safe areas, and turns
the surface translation off. The composer's single tree keeps the editing
surface at the same position in both modes, so the mounted editor, its native
buffer, selection, and focus survive the toggle and Send's clear always reaches
the live input. The one remount left is a viewport crossing the compact
breakpoint: the app shell swaps its gesture wrapper and the workspace screen
mounts tabs in different trees, so every screen below remounts. The draft owner
restores the text and the editing session restores the caret and focus intent.
New workspace keys its sessions by draft in the app-level store for the same
reason. The centered form honors fullscreen by style alone: no centering, no
translation, setup fields hidden, slot filling the stationary viewport. The
global exit action (Escape, hardware back) is registered only for the active
composer: a retained tab keeps its fullscreen editor mounted while hidden, the
sheet suspends pane focus, and the composer gives up input from the moment the
user asks for the compact agent list or explorer, before the panel's motion
settles. Pane focus itself stays put under those panels: the agent panel reads
it as "the user is in this chat" for attention clearing, and opening the agent
list must not clear the attention it shows. Focus restoration follows pane
focus, not the active composer. The editor keeps its focus intent only across
a blur that lands while its pane is not the user's (a retained tab hidden, a
sheet over it) and refocuses when the pane comes back. A blur while the pane is
theirs drops the intent in every layout mode, and the keyboard the compact
panels dismiss when they settle is such a blur, so closing the agent list or
explorer never reopens the keyboard. Keying restoration on the active composer
made that dismiss look like a hidden-tab blur and refocused on close.
The band beside the inline editor where the fullscreen glyph sits is the input's
own right padding, so a tap anywhere in it focuses the editor whether or not the
glyph shows. On web the slot is a fixed box over the visible viewport, which is
also what lets a zoomed mobile page pan under it. Inside the slot, `ComposerFullscreenBody` reserves the keyboard
height as a bottom margin at move start and end (boundary-time, like the
capacity reservation) while `ComposerFullscreenFooter` rides the keyboard per
frame with `KeyboardTranslateView`, so Send lands on the keyboard without a gap
and without animating the editor's height. The screen that owns the chrome
unmounts it: the workspace screen reads `useComposerTabFullscreen` for the
active tab and New workspace reads the mounted composer's session. Hosts that
render the dock pass the same `ComposerEditingSession` to the `Composer` so the
dock, chrome, and editor agree on one fullscreen state. Escape (web overlay
registration, focus untouched) and the Android back button leave fullscreen.
Keep setup fields out of the composer's shrink chain. New workspace supplies its
setup fields as the first dock child so they move up under the header as the draft
grows. Putting the fields and composer in one bounded, shrinkable column made
@@ -302,11 +343,20 @@ Do not treat `onChange(-1)` as a close by itself. In a stacked
another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
track phase.
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`. This
includes native centered dialogs: separate React Native Modals cannot present a root-owned
sibling dialog over an existing iOS presenter. The one exception is the
[workspace sheet](explorer-sidebar.md#workspace-sheet), an in-tree Gorhom `BottomSheet` that hosts a
whole workspace panel.
All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`, with no
exceptions: one stack means a sheet opened from any sheet lands on top of it and uncovers it on
close. This includes native centered dialogs: separate React Native Modals cannot present a
root-owned sibling dialog over an existing iOS presenter. A sheet that must draw inside a subtree
(the [sheet tabs](explorer-sidebar.md#sheets) draw inside the compact workspace column so their
panels read the workspace providers) presents from inside a `SheetHostScope`, which moves the
portal host and container measurement while the sheet still joins the same stack. That export is
what `patches/@gorhom+bottom-sheet+*.patch` adds.
A sheet whose owner may veto the close (unsaved work) passes `enableDismissOnClose={false}`. Every
close path then parks the sheet at index -1 with its content mounted; the owner hears
`onChange(-1)`, runs its guard, and either snaps back to 0 or drops its state, which unmounts the
sheet and takes it off the stack. Calling `dismiss()` on such a sheet bypasses the guard and
unmounts it, so its backdrop and Back press call `close()`.
The wrapper must give Gorhom an object ref. Gorhom accepts a callback ref at the React boundary
but stores it in the modal queue and later reads `.current`; provider dismissal, replacement,
@@ -316,9 +366,10 @@ forwards commands to the current Gorhom handle.
Request a backdrop with `backdropOpacity`; do not install a caller-owned backdrop. On web,
Gorhom's default backdrop closes its own sheet, even when another modal is above it. Its animated
pointer-event changes can expose a lower backdrop during a transition. The shared web backdrop
keeps its press target until portal teardown and asks the provider to dismiss the top modal.
Gorhom's presentation queue remains the authority; backdrop mount order is irrelevant. Native
retains Gorhom's gesture backdrop and the same push/replace presentation semantics.
keeps its press target until portal teardown and asks the provider to dismiss the top modal
(a guarded sheet closes itself instead, see above). Gorhom's presentation queue remains the
authority; backdrop mount order is irrelevant. Native retains Gorhom's gesture backdrop and the
same push/replace presentation semantics.
## Gotcha 7 — A sheet cannot read context from its call site
+2 -1
View File
@@ -21,7 +21,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label.
- **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**.
- **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md).
- **Workspace sheet** — The compact sheet that holds one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `sheetByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `WorkspaceSheetHost` (`packages/app/src/screens/workspace/workspace-sheet-host.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#workspace-sheet).
- **Sheet** — Any bottom sheet; there is one kind and one app-wide stack, so a sheet opened from a sheet lands on top of it and closing it uncovers the one below. Code: `IsolatedBottomSheetModal` (`packages/app/src/components/ui/isolated-bottom-sheet-modal`). Forbidden: "Workspace sheet", "modal sheet" as a distinct kind. See [floating-panels.md](floating-panels.md).
- **Sheet tab** — A compact-layout sheet holding one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `SheetTab` and `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `SheetTabs` (`packages/app/src/screens/workspace/sheet-tabs.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#sheets).
- **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel".
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
+8
View File
@@ -52,6 +52,11 @@ is another, with its own icons and hover-revealed actions — but both hand the
and take `closeOnSelect` to say whether choosing them ends the surface. A row that owns its press
outright leaves the surface open behind whatever it opened, and skips the iOS wait.
A row whose result opens a sheet passes `closeOnSelect={false}`: the sheet stacks over the menu
sheet and uncovers it on close, so the menu is where the user lands next. A row that opens a tab
closes, because the tab replaces the pane the menu belongs to. The subagents track asks the layout
store which one a row is (`useSubagentOpensInSheet`), since the answer changes with the layout.
## Pages
A submenu is a page, declared as data on the surface and reached by a `MenuSubTrigger` whose `id`
@@ -193,6 +198,9 @@ its own.
backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes.
- **Sheet stacking and backdrop dismissal belong to `IsolatedBottomSheetModal`.** Menus only
request a backdrop opacity. See [floating-panels.md](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth).
- **A menu closes when its panel leaves the screen.** `MenuSurface` closes the menu when its
retained panel goes inactive. Hiding the surface alone leaves the open state behind, and the
menu would reappear with the panel on the next tab switch.
- **Retained panels own visibility.** The shared menu surface unmounts when its panel becomes
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
+6
View File
@@ -86,6 +86,12 @@ buildNpmPackage {
# Expo's web build pulls in some pre-bundled assets; ensure it doesn't try
# to phone home during the build.
CI = "1";
# Metro's worker runs out of heap transforming the generated
# ws-outbound.aot.js validator (about 12 MB). Node's default heap limit is
# 2 GB on machines with less than 15 GB of memory, such as GitHub's 7 GB
# macOS runners, and 4 GB above that. 4096 MB is that 4 GB ceiling, so this
# only raises the limit on smaller builders, like desktop-release.yml does.
NODE_OPTIONS = "--max-old-space-size=4096";
};
buildPhase = ''
@@ -223,6 +223,7 @@ test.describe("Add Project command-center flow", () => {
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "directory-search");
await expect(addProjectFlowInput(page)).toBeFocused();
await page.keyboard.type(projectPickerFixture.fuzzyQuery);
await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName, {
timeout: 30_000,
@@ -16,6 +16,7 @@ import {
} from "../support/helpers/assistant-images";
import { expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import path from "node:path";
const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
imageWorkspace: async ({ page: _page }, provide) => {
@@ -28,6 +29,42 @@ const test = base.extend<{ imageWorkspace: SeededWorkspace }>({
},
});
test("assistant images at paths with spaces, Unicode and literal percent escapes render", async ({
imageWorkspace: workspace,
page,
}) => {
test.setTimeout(120_000);
const image = await createSmallAssistantPng(workspace, {
alt: "Angle destination image",
fileName: "My image Мои.png",
});
const agent = await createSettledMockAgent(workspace, "Encoded image paths");
await emitSettledAssistantImage(workspace.client, agent, image, `<${image.relativePath}>`);
await openAssistantImageTimeline(page, agent);
await expectAssistantImageRendered(page, image);
const encodedImage = { ...image, alt: "Encoded destination image" };
await emitSettledAssistantImage(
workspace.client,
agent,
encodedImage,
encodeURI(path.join(workspace.repoPath, image.relativePath)),
);
await expectAssistantImageRendered(page, encodedImage);
const literalPercentImage = await createSmallAssistantPng(workspace, {
alt: "Literal percent filename image",
fileName: "literal%20name.png",
});
await emitSettledAssistantImage(
workspace.client,
agent,
literalPercentImage,
encodeURI(literalPercentImage.relativePath),
);
await expectAssistantImageRendered(page, literalPercentImage);
});
test("switching between settled agent tabs keeps a real assistant PNG rendered", async ({
imageWorkspace: workspace,
page,
@@ -0,0 +1,48 @@
import { expect, test } from "../support/fixtures";
import type { Page } from "@playwright/test";
import { openSettingsSection } from "../support/helpers/settings";
async function openAppearance(page: Page) {
await page.goto("/settings");
await openSettingsSection(page, "appearance");
}
async function setFontFamily(page: Page, name: string, family: string) {
const input = page.getByRole("textbox", { name });
await input.fill(family);
await input.press("Tab");
}
for (const name of ["Code font family", "Interface font family"]) {
test(`keeps settings usable after an unfinished quote in ${name}`, async ({ page }, testInfo) => {
await openAppearance(page);
await setFontFamily(page, name, "'Fira Code', monospace");
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
await setFontFamily(page, name, "'foo");
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
await page.screenshot({ path: testInfo.outputPath("rejected-font.png") });
await page.reload();
await expect(page.getByRole("textbox", { name })).toHaveValue("'Fira Code', monospace");
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
await setFontFamily(page, name, "");
await expect(page.getByRole("textbox", { name })).toHaveValue("");
});
}
test("opens settings with defaults when a malformed font was already saved", async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ monoFontFamily: "'foo", uiFontFamily: '"foo' }),
);
});
await openAppearance(page);
await expect(page.getByRole("textbox", { name: "Code font family" })).toHaveValue("");
await expect(page.getByRole("textbox", { name: "Interface font family" })).toHaveValue("");
await expect(page.getByRole("button", { name: "Appearance", exact: true })).toBeInViewport();
await expect(page.getByText("Theme", { exact: true }).first()).toHaveCSS("font-size", "12px");
});
@@ -4,18 +4,23 @@ import { expect, test } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { openSubagentFromCompactTrack, seedSubagent } from "../support/helpers/subagents";
import {
closeWorkspaceSheet,
expectSubagentRowVisible,
openSubagentFromCompactTrack,
seedSubagent,
} from "../support/helpers/subagents";
import {
closeSheet,
expectOpenTabCount,
openWorkspaceSheetAsTab,
openSheetAsTab,
expectDeclinedDiscardPrompt,
expectWorkspaceSheetAtSnap,
replaceWorkspaceSheetFileText,
expectSheetAtSnap,
replaceSheetFileText,
selectTabFromSwitcher,
tapWorkspaceSheetBackdrop,
workspaceSheet,
} from "../support/helpers/workspace-sheet";
sheet,
sheetHeader,
tapSheetBackdrop,
} from "../support/helpers/sheet";
const FILE_LINK = "linked.ts:3";
@@ -55,47 +60,90 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
await expect(fileLink).toBeVisible({ timeout: 15_000 });
await fileLink.click();
const sheet = workspaceSheet(page, "linked.ts");
await expect(sheet).toBeVisible();
await expect(sheet.getByText("three")).toBeVisible();
await expectWorkspaceSheetAtSnap(page, "linked.ts");
await expect(sheet.getByRole("button", { name: "Open as tab" })).toHaveCount(0);
const fileSheet = sheet(page, "linked.ts");
await expect(fileSheet).toBeVisible();
await expect(fileSheet.getByText("three")).toBeVisible();
await expectSheetAtSnap(page, "linked.ts");
await expect(
sheetHeader(page, "linked.ts").getByRole("button", { name: "Open as tab" }),
).toHaveCount(0);
await expectOpenTabCount(page, 1);
await closeWorkspaceSheet(page, "linked.ts");
await closeSheet(page, "linked.ts");
});
await test.step("a subagent opens in a sheet without its composer", async () => {
await test.step("a subagent opens in a sheet over the subagents list", async () => {
await openSubagentFromCompactTrack(page, child.id);
const sheet = workspaceSheet(page, child.title);
await expect(sheet).toBeVisible();
await expectWorkspaceSheetAtSnap(page, child.title);
await expect(sheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
const childSheet = sheet(page, child.title);
await expect(childSheet).toBeVisible();
await expectSheetAtSnap(page, child.title);
await expect(childSheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
// The composer's model, thinking, and mode stand in for it, resolved the same way. An idle
// seeded agent has no context usage yet.
await expect(childSheet.getByTestId("agent-pane-subtitle")).toHaveText(
"Ten second stream · Low · Load test",
);
await expectOpenTabCount(page, 1);
});
await test.step("Open as tab promotes the subagent to a full tab", async () => {
await openWorkspaceSheetAsTab(page, child.title);
await test.step("closing the subagent's sheet uncovers the list", async () => {
await closeSheet(page, child.title);
await expectSubagentRowVisible(page, child.id);
});
await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
await page.getByTestId(`subagents-track-row-${child.id}`).click();
await expectSheetAtSnap(page, child.title);
await openSheetAsTab(page, child.title);
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
await expectOpenTabCount(page, 2);
await expectComposerVisible(page);
});
await test.step("a sheet with unsaved edits closes only through the guarded close", async () => {
await test.step("the list the subagent was opened from closed with its tab", async () => {
await selectTabFromSwitcher(page, "Sheet parent");
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
});
await test.step("once its tab closes, the subagent reopens in a sheet that keeps the list", async () => {
await page
.getByRole("button", { name: /Switch tabs/ })
.filter({ visible: true })
.click();
await expect(page.getByText("Workspace tabs", { exact: true })).toBeVisible();
const menuBase = `workspace-tab-menu-agent_${child.id}`;
await page.getByTestId(`${menuBase}-trigger`).click();
await page.getByTestId(`${menuBase}-close`).click();
await expectOpenTabCount(page, 1);
// Closing a tab keeps the switcher open, with the parent as the tab left. Its backdrop is
// tapped once the row menu's own sheet has gone.
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
await tapSheetBackdrop(page);
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
await openSubagentFromCompactTrack(page, child.id);
await expectSheetAtSnap(page, child.title);
await closeSheet(page, child.title);
await expectSubagentRowVisible(page, child.id);
await tapSheetBackdrop(page);
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
});
await test.step("a sheet with unsaved edits closes only through the guarded close", async () => {
await page.getByText(FILE_LINK, { exact: true }).click();
const sheet = workspaceSheet(page, "linked.ts");
await expect(sheet).toBeVisible();
const fileSheet = sheet(page, "linked.ts");
await expect(fileSheet).toBeVisible();
// Hold the autosave so the edit stays unsaved.
gate.holdNextClientRequest("fs.file.write.request");
await replaceWorkspaceSheetFileText(page, "linked.ts", "edited in the sheet");
await replaceSheetFileText(page, "linked.ts", "edited in the sheet");
await gate.waitForHeldClientRequest();
await expectDeclinedDiscardPrompt(page, () => tapWorkspaceSheetBackdrop(page));
await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet");
await expectDeclinedDiscardPrompt(page, () => tapSheetBackdrop(page));
await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet");
await expectDeclinedDiscardPrompt(page, () =>
sheet.getByRole("button", { name: "Close" }).click(),
sheetHeader(page, "linked.ts").getByRole("button", { name: "Close" }).click(),
);
await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet");
await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet");
gate.releaseHeldClientRequest();
await expect.poll(() => readFile(linkedPath, "utf8")).toBe("edited in the sheet");
@@ -9,7 +9,12 @@ import { expectWorkspaceTabVisible, openSessions } from "../support/helpers/arch
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { getServerId } from "../support/helpers/server-id";
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
import { expectMobileAgentSidebarVisible } from "../support/helpers/sidebar";
import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
openMobileAgentSidebar,
} from "../support/helpers/sidebar";
import { expectNewWorkspaceProjectSelected } from "../support/helpers/new-workspace";
const TEST_COMMANDS = [
@@ -96,16 +101,6 @@ interface PopoverFrameRecorderWindow extends Window {
__stopComposerAutocompleteFrameRecorder?: () => void;
}
async function getTopTestIdAtPoint(page: Page, x: number, y: number) {
return page.evaluate(
([pointX, pointY]) => {
const element = document.elementFromPoint(pointX, pointY);
return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null;
},
[x, y],
);
}
async function installListCommandsStub(page: Page): Promise<void> {
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
@@ -701,19 +696,15 @@ test.describe("Composer autocomplete", () => {
timeout: 30_000,
});
await page.getByRole("button", { name: "Open menu" }).click();
// The sidebar covers the composer, so the composer gives up its popover while the
// sidebar is open and brings it back once the sidebar closes.
await openMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
await expect(popover).toHaveCount(0);
const popoverBox = await popover.boundingBox();
expect(popoverBox).not.toBeNull();
const topTestId = await getTopTestIdAtPoint(
page,
popoverBox!.x + popoverBox!.width / 2,
popoverBox!.y + popoverBox!.height / 2,
);
expect(topTestId).not.toBe("composer-autocomplete-popover");
await closeMobileAgentSidebar(page);
await expectMobileAgentSidebarHidden(page);
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
} finally {
await agent.cleanup();
}
@@ -1,13 +1,30 @@
import { expect, test, type Page } from "../support/fixtures";
import {
composerLocator,
blurComposerInput,
enterComposerFullscreen,
exitComposerFullscreen,
expectComposerDraft,
expectComposerFocused,
expectComposerFullscreenControls,
expectComposerNotFocused,
expectComposerVisible,
expectWorkspaceHeaderVisible,
resizeComposerViewport,
submitMessage,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
closeMobileAgentSidebar,
closeMobileExplorer,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
expectMobileExplorerHidden,
expectMobileExplorerVisible,
swipeOpenMobileAgentSidebar,
swipeOpenMobileExplorer,
} from "../support/helpers/sidebar";
test("submitting a message leaves the composer ready for the next message", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
@@ -76,3 +93,76 @@ test.describe("touch screen wide enough for the desktop layout", () => {
}
});
});
const COMPACT_PANELS = [
{
name: "agent list",
open: swipeOpenMobileAgentSidebar,
expectOpen: expectMobileAgentSidebarVisible,
close: closeMobileAgentSidebar,
expectClosed: expectMobileAgentSidebarHidden,
},
{
name: "Explorer",
open: swipeOpenMobileExplorer,
expectOpen: expectMobileExplorerVisible,
close: closeMobileExplorer,
expectClosed: expectMobileExplorerHidden,
},
] as const;
for (const panel of COMPACT_PANELS) {
test(`closing the compact ${panel.name} does not restore composer focus`, async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-focus-panel-",
title: `Composer focus under ${panel.name}`,
});
const draft = "Line one\nLine two\nLine three";
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await test.step("an untouched editor stays untouched", async () => {
await expectComposerNotFocused(page);
await panel.open(page);
await panel.expectOpen(page);
await panel.close(page);
await panel.expectClosed(page);
await expectComposerNotFocused(page);
});
await test.step("an inline editor blurred under the panel stays blurred", async () => {
await composerLocator(page).fill(draft);
await composerLocator(page).click();
await expectComposerFocused(page);
await panel.open(page);
await panel.expectOpen(page);
await blurComposerInput(page);
await panel.close(page);
await panel.expectClosed(page);
await expectComposerNotFocused(page);
await expectComposerDraft(page, draft);
});
await test.step("a fullscreen editor blurred under the panel stays blurred", async () => {
await composerLocator(page).click();
await enterComposerFullscreen(page);
await expectComposerFullscreenControls(page);
await expectComposerFocused(page);
await panel.open(page);
await panel.expectOpen(page);
await blurComposerInput(page);
await panel.close(page);
await panel.expectClosed(page);
await expectComposerNotFocused(page);
await expectComposerFullscreenControls(page);
await expectComposerDraft(page, draft);
await exitComposerFullscreen(page);
await expectWorkspaceHeaderVisible(page);
});
} finally {
await agent.cleanup();
}
});
}
@@ -20,6 +20,9 @@ import {
queueComposerWithButton,
configureComposerQueueSend,
activateRetainedComposerTab,
switchComposerTabWithKeyboard,
expectWorkspaceHeaderHidden,
expectWorkspaceHeaderVisible,
recordVisibleComposerGeometry,
expectNoCollapsedVisibleComposer,
expectAttachmentPill,
@@ -27,8 +30,15 @@ import {
startRunningMockAgent,
composerLocator,
expectComposerVisible,
expectComposerFullscreenFitsVisibleViewport,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
closeMobileAgentSidebar,
expectMobileAgentSidebarHidden,
expectMobileAgentSidebarVisible,
swipeOpenMobileAgentSidebar,
} from "../support/helpers/sidebar";
async function composerHeight(page: Page): Promise<number> {
return composerLocator(page).evaluate((element) => element.getBoundingClientRect().height);
@@ -156,6 +166,26 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
}
});
/** Freeze the page's timers and animation frames until `page.clock.resume()`. */
async function pausePageClock(page: Page): Promise<void> {
const now = Date.now();
await page.clock.install({ time: now });
// The installed clock advances between protocol calls; pause at a future time.
await page.clock.pauseAt(now + 60_000);
}
/** The send error shares the footer with Send, so whatever keeps Send visible keeps it visible. */
async function expectFullscreenErrorAboveSend(page: Page): Promise<void> {
const frame = page.getByTestId("composer-fullscreen");
const alertBox = (await frame.getByRole("alert").boundingBox())!;
const sendBox = (await page
.getByRole("button", { name: "Send message", exact: true })
.boundingBox())!;
const editorBox = (await composerLocator(page).boundingBox())!;
expect(alertBox.y).toBeGreaterThanOrEqual(editorBox.y + editorBox.height);
expect(alertBox.y + alertBox.height).toBeLessThanOrEqual(sendBox.y);
}
async function writeCompactDraft(page: Page, text: string): Promise<void> {
await composerLocator(page).fill(text);
await expect(composerLocator(page)).toHaveValue(text);
@@ -177,7 +207,7 @@ test("compact long drafts stop growing before the header", async ({ page }) => {
}
});
test("compact fullscreen editing preserves the live draft and selection and stays open after send", async ({
test("compact fullscreen editing preserves the live draft and selection and returns to chat after send", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
@@ -188,47 +218,109 @@ test("compact fullscreen editing preserves the live draft and selection and stay
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await attachComposerTestImage(page);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await writeCompactDraft(page, "First line\nSecond line");
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toHaveCount(0);
await writeCompactDraft(page, "First line\nSecond line\nThird line");
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
await writeCompactDraft(
page,
"A sentence with enough words to wrap across several rendered lines in a narrow input. ".repeat(
4,
),
);
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
await selectComposerDraftRange(page, 4, 17);
await enterComposerFullscreen(page);
await expectComposerFullscreenControls(page);
await expectComposerDraftSelection(page, 4, 17);
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
await writeCompactDraft(page, "Edited fullscreen draft\nSecond line\nThird line");
await selectComposerDraftRange(page, 3, 12);
await exitComposerFullscreen(page);
await expect(composerLocator(page)).toHaveValue(
"Edited fullscreen draft\nSecond line\nThird line",
);
await expectComposerDraftSelection(page, 3, 12);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await expect(composerLocator(page)).toBeFocused();
await enterComposerFullscreen(page);
await writeCompactDraft(page, "Short draft");
await expectComposerFullscreenControls(page);
await selectComposerDraftRange(page, 0, 5);
await resizeComposerViewport(page, "landscape");
await expectComposerFullscreenControls(page);
await expectComposerDraftSelection(page, 0, 5);
await expect(composerLocator(page)).toHaveValue("Short draft");
await expect(composerLocator(page)).toBeFocused();
await test.step("the affordance appears after two rendered lines, including soft wraps", async () => {
await attachComposerTestImage(page);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await writeCompactDraft(page, "First line\nSecond line");
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toHaveCount(0);
await writeCompactDraft(page, "First line\nSecond line\nThird line");
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
await writeCompactDraft(
page,
"A sentence with enough words to wrap across several rendered lines in a narrow input. ".repeat(
4,
),
);
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
});
await test.step("fullscreen covers the header and keeps the live draft and selection", async () => {
await selectComposerDraftRange(page, 4, 17);
await enterComposerFullscreen(page);
await expectComposerFullscreenControls(page);
await expectWorkspaceHeaderHidden(page);
await expectComposerDraftSelection(page, 4, 17);
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
await writeCompactDraft(page, "Edited fullscreen draft\nSecond line\nThird line");
await selectComposerDraftRange(page, 3, 12);
});
await test.step("exit restores the inline composer with the fullscreen edits", async () => {
await exitComposerFullscreen(page);
await expectWorkspaceHeaderVisible(page);
await expect(composerLocator(page)).toHaveValue(
"Edited fullscreen draft\nSecond line\nThird line",
);
await expectComposerDraftSelection(page, 3, 12);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await expect(composerLocator(page)).toBeFocused();
});
await test.step("sending inline after a fullscreen edit clears the visible editor", async () => {
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
await writeCompactDraft(page, "Typed after the fullscreen cycle");
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
});
await test.step("sending inside fullscreen returns to chat and clears the editor", async () => {
await writeCompactDraft(page, "First line\nSecond line\nThird line");
await enterComposerFullscreen(page);
await writeCompactDraft(page, "Short draft");
await expectComposerFullscreenControls(page);
await selectComposerDraftRange(page, 0, 5);
await resizeComposerViewport(page, "landscape");
await expectComposerFullscreenControls(page);
await expectComposerDraftSelection(page, 0, 5);
await expect(composerLocator(page)).toHaveValue("Short draft");
await expect(composerLocator(page)).toBeFocused();
await resizeComposerViewport(page, "portrait");
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expectWorkspaceHeaderVisible(page);
await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0);
});
await test.step("typing and sending keep working after leaving fullscreen again", async () => {
await writeCompactDraft(page, "First line\nSecond line\nThird line");
await enterComposerFullscreen(page);
await writeCompactDraft(page, "Typed inside fullscreen");
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
await exitComposerFullscreen(page);
await expectWorkspaceHeaderVisible(page);
await expect(composerLocator(page)).toHaveValue("Typed inside fullscreen");
await writeCompactDraft(page, "Typed inside fullscreen, finished inline");
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await writeCompactDraft(page, "One more inline message");
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
});
} finally {
await agent.cleanup();
}
});
test("a caret inside an unpublished edit survives a breakpoint remount", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-remount-caret-",
title: "Remount caret",
});
try {
await resizeComposerViewport(page, "portrait");
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expectComposerFullscreenControls(page);
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled();
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await writeCompactDraft(page, "Short draft");
await selectComposerDraftRange(page, 5, 5);
// The draft owner publishes web edits after the next paint. A rotation inside that
// window remounts the editor before the edit has reached the draft store; the paused
// clock holds the page there instead of racing the frame.
await pausePageClock(page);
await composerLocator(page).press("X");
await resizeComposerViewport(page, "landscape");
await page.clock.resume();
await expect(composerLocator(page)).toHaveValue("ShortX draft");
await expectComposerDraftSelection(page, 6, 6);
await expect(composerLocator(page)).toBeFocused();
await composerLocator(page).press("Y");
await expect(composerLocator(page)).toHaveValue("ShortXY draft");
} finally {
await agent.cleanup();
}
@@ -250,15 +342,86 @@ test("fullscreen submit rejection retains the editable draft and supports retry"
await writeCompactDraft(page, draft);
await enterComposerFullscreen(page);
await sendComposerWithButton(page);
await expect(page.getByTestId("composer-fullscreen").getByRole("alert")).toHaveText(
"Requested mock prompt rejection",
);
await expectWorkspaceHeaderVisible(page);
await expect(page.getByRole("alert")).toHaveText("Requested mock prompt rejection");
await enterComposerFullscreen(page);
await expectFullscreenErrorAboveSend(page);
await expect(composerLocator(page)).toHaveValue(draft);
await expect(composerLocator(page)).toBeEditable();
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expectComposerFullscreenControls(page);
await expectWorkspaceHeaderVisible(page);
} finally {
await agent.cleanup();
}
});
async function blurComposer(page: Page): Promise<void> {
await composerLocator(page).blur();
await expect(composerLocator(page)).not.toBeFocused();
}
/** Mid-band at the given height and near the editor's bottom edge. */
async function reservedBandPoints(
page: Page,
bandX: number,
topY: number,
): Promise<Array<{ x: number; y: number }>> {
const editor = (await composerLocator(page).boundingBox())!;
return [
{ x: bandX, y: topY },
{ x: bandX, y: editor.y + editor.height - 6 },
];
}
test("the reserved band beside the inline editor focuses it", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-band-focus-",
title: "Composer band focus",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const exitButton = page.getByRole("button", { name: "Exit fullscreen", exact: true });
const fullscreenButton = page.getByRole("button", { name: "Fullscreen", exact: true });
const longDraft = "Band first line\nBand second line\nBand third line";
await writeCompactDraft(page, longDraft);
await expect(fullscreenButton).toBeVisible();
const button = (await fullscreenButton.boundingBox())!;
const bandX = button.x + button.width / 2;
await test.step("with the fullscreen button shown, the band under it focuses the editor", async () => {
for (const point of await reservedBandPoints(page, bandX, button.y + button.height + 10)) {
await blurComposer(page);
await page.mouse.click(point.x, point.y);
await expect(composerLocator(page)).toBeFocused();
await expect(composerLocator(page)).toHaveValue(longDraft);
await expect(exitButton).toHaveCount(0);
}
});
await test.step("the button itself still toggles fullscreen", async () => {
await fullscreenButton.click();
await expectComposerFullscreenControls(page);
await exitComposerFullscreen(page);
await expect(exitButton).toHaveCount(0);
await expect(composerLocator(page)).toHaveValue(longDraft);
});
await test.step("with no fullscreen button, the whole band still focuses the editor", async () => {
await writeCompactDraft(page, "Short");
await expect(fullscreenButton).toHaveCount(0);
const editor = (await composerLocator(page).boundingBox())!;
for (const point of await reservedBandPoints(page, bandX, editor.y + 6)) {
await blurComposer(page);
await page.mouse.click(point.x, point.y);
await expect(composerLocator(page)).toBeFocused();
await expect(composerLocator(page)).toHaveValue("Short");
await expect(exitButton).toHaveCount(0);
}
});
} finally {
await agent.cleanup();
}
@@ -280,8 +443,7 @@ test("fullscreen Send keeps the configured queue behavior while an agent runs",
await expectComposerFullscreenControls(page);
await queueComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expectComposerFullscreenControls(page);
await exitComposerFullscreen(page);
await expectWorkspaceHeaderVisible(page);
await expectQueuedMessageButton(page);
} finally {
await agent.cleanup();
@@ -333,15 +495,28 @@ test("retained agent tabs preserve fullscreen editing and paste when switched aw
const draft = "Live first line\nLive second line\nLive third line";
await writeCompactDraft(page, draft);
await enterComposerFullscreen(page);
await expectWorkspaceHeaderHidden(page);
await selectComposerDraftRange(page, 4, 17);
await activateRetainedComposerTab(page, first.title);
// Fullscreen covers the compact header, so a phone leaves the tab by exiting first;
// a desktop-width window still has the tab shortcut.
await resizeComposerViewport(page, "landscape");
await switchComposerTabWithKeyboard(page, "previous");
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
// The exit action belongs to the active pane: Escape here leaves the hidden tab alone.
await composerLocator(page).press("Escape");
await switchComposerTabWithKeyboard(page, "next");
await expectComposerFullscreenControls(page);
await switchComposerTabWithKeyboard(page, "previous");
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
await resizeComposerViewport(page, "portrait");
await expectWorkspaceHeaderVisible(page);
await writeCompactDraft(page, "Other tab draft");
await shrinkVisibleViewport(page);
await activateRetainedComposerTab(page, second.title);
await expectComposerFullscreenControls(page);
await expectWorkspaceHeaderHidden(page);
await expect(composerLocator(page)).toHaveValue(draft);
await expectFullscreenInsideVisibleViewport(page);
await expectComposerFullscreenFitsVisibleViewport(page);
await expectComposerDraftSelection(page, 4, 17);
await expect(composerLocator(page)).toBeFocused();
expect(await pasteComposerTestImage(page)).toBe(true);
@@ -352,6 +527,36 @@ test("retained agent tabs preserve fullscreen editing and paste when switched aw
}
});
test("the compact sidebar over a fullscreen editor leaves the exit action alone", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-fullscreen-sidebar-",
title: "Fullscreen under sidebar",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
const draft = "Line one\nLine two\nLine three";
await writeCompactDraft(page, draft);
await enterComposerFullscreen(page);
await expectWorkspaceHeaderHidden(page);
// Fullscreen hides the header's menu button; the edge swipe still opens the list over it.
await swipeOpenMobileAgentSidebar(page);
await expectMobileAgentSidebarVisible(page);
// The list covers the pane, so the editor's exit action is not armed.
await page.keyboard.press("Escape");
await closeMobileAgentSidebar(page);
await expectMobileAgentSidebarHidden(page);
await expectComposerFullscreenControls(page);
await expect(composerLocator(page)).toHaveValue(draft);
await exitComposerFullscreen(page);
await expectWorkspaceHeaderVisible(page);
} finally {
await agent.cleanup();
}
});
test("retained inline composers keep usable capacity through hidden tab geometry", async ({
page,
}) => {
@@ -408,30 +613,6 @@ async function panVisibleViewport(page: Page): Promise<void> {
await cdp.detach();
}
async function expectFullscreenInsideVisibleViewport(page: Page): Promise<void> {
await expect
.poll(() =>
page.getByTestId("composer-fullscreen").evaluate((element) => {
const viewport = window.visualViewport!;
const frame = element.getBoundingClientRect();
const send = element.querySelector('[aria-label="Send message"]')!.getBoundingClientRect();
return {
frameFits:
Math.abs(frame.top - viewport.offsetTop) < 1 &&
Math.abs(frame.left - viewport.offsetLeft) < 1 &&
Math.abs(frame.height - viewport.height) < 1 &&
Math.abs(frame.width - viewport.width) < 1,
sendFits:
send.top >= viewport.offsetTop &&
send.bottom <= viewport.offsetTop + viewport.height &&
send.left >= viewport.offsetLeft &&
send.right <= viewport.offsetLeft + viewport.width,
};
}),
)
.toEqual({ frameFits: true, sendFits: true });
}
test("fullscreen Send follows the visible viewport when it shrinks and pans", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-visible-viewport-",
@@ -443,11 +624,11 @@ test("fullscreen Send follows the visible viewport when it shrinks and pans", as
await expectComposerVisible(page);
await writeCompactDraft(page, "First line\nSecond line\nThird line");
await enterComposerFullscreen(page);
await expectFullscreenInsideVisibleViewport(page);
await expectComposerFullscreenFitsVisibleViewport(page);
await shrinkVisibleViewport(page);
await expectFullscreenInsideVisibleViewport(page);
await expectComposerFullscreenFitsVisibleViewport(page);
await panVisibleViewport(page);
await expectFullscreenInsideVisibleViewport(page);
await expectComposerFullscreenFitsVisibleViewport(page);
} finally {
await agent.cleanup();
}
@@ -116,6 +116,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
);
expect(workspaces).toHaveLength(1);
gate.release();
await gate.waitForCreated();
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
const first = (await client.fetchAgents()).entries[0]!.agent;
await client.waitForFinish(first.id, 20_000);
@@ -128,6 +129,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
await fillComposerDraft(page, secondPrompt);
await pressSubmitBeforeTheNextRender(page, "Send message");
await gate.waitForCreated();
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
const second = (await client.fetchAgents()).entries.find(
({ agent }) => agent.id !== first.id,
@@ -206,21 +208,40 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
async function holdLegacyAgentCreation(page: Page, port: number) {
const readers = await loadSessionMessageReaders();
const arrived = Promise.withResolvers<void>();
let created = Promise.withResolvers<void>();
let createRequestId: string | undefined;
let release = () => {};
let holding = true;
await page.routeWebSocket(new RegExp(`:${port}/ws`), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => {
if (holding && readers.client(frame)?.type === "create_agent_request") {
const message = readers.client(frame);
if (message?.type === "create_agent_request") createRequestId = message.requestId;
if (holding && message?.type === "create_agent_request") {
holding = false;
release = () => server.send(frame);
arrived.resolve();
} else server.send(frame);
});
server.onMessage((frame) => browser.send(frame));
server.onMessage((frame) => {
const message = readers.server(frame);
if (
message?.type === "status" &&
message.payload.status === "agent_created" &&
message.payload.requestId === createRequestId
) {
created.resolve();
}
browser.send(frame);
});
});
return {
waitForRequest: () => arrived.promise,
async waitForCreated() {
// Agent-list membership includes initialization, before the initial prompt starts.
await created.promise;
created = Promise.withResolvers<void>();
},
release() {
const send = release;
release = () => {};
@@ -0,0 +1,29 @@
import { test } from "../support/fixtures";
import {
addCustomModel,
expectCustomModelRetained,
openCustomModels,
removeCustomModel,
} from "../support/helpers/custom-models";
const longModelId = "custom-model-with-a-long-name-".repeat(6);
const shortModelId = "custom-short";
for (const width of [1200, 390]) {
test(`removes a long custom model at ${width}px without removing another model`, async ({
page,
}) => {
await page.setViewportSize({ width: 1200, height: 1000 });
await openCustomModels(page);
await addCustomModel(page, shortModelId);
await addCustomModel(page, longModelId);
await test.step("remove the long model at the target width", async () => {
await page.setViewportSize({ width, height: 1000 });
await removeCustomModel(page, longModelId);
await expectCustomModelRetained(page, shortModelId);
});
await removeCustomModel(page, shortModelId);
});
}
@@ -2,10 +2,10 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { openFileExplorer } from "../support/helpers/file-explorer";
import { downloadFileFromExplorer, openFileExplorer } from "../support/helpers/file-explorer";
import { openChangesPanel } from "../support/helpers/workspace-tabs";
import { gotoWorkspace } from "../support/helpers/launcher";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { daemonWsRoutePattern, getE2EDaemonPort } from "../support/helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
let workspace: SeededWorkspace;
@@ -444,3 +444,25 @@ test("hides unsupported file operations and revert actions", async ({ page }) =>
await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0);
await expect(page.getByTestId("diff-file-0-revert")).toHaveCount(0);
});
test("downloads a file over direct TCP with its original filename and bytes", async ({
page,
}, testInfo) => {
const fileName = "rapport données.bin";
const source = Buffer.from([0, 1, 2, 127, 128, 255, 10]);
await writeFile(path.join(workspace.workspaceDirectory, fileName), source);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
const downloaded = await downloadFileFromExplorer(page, fileName);
const url = new URL(downloaded.url());
expect(url.protocol).toBe("http:");
expect(url.port).toBe(getE2EDaemonPort());
expect(url.pathname).toBe("/api/files/download");
expect(url.searchParams.get("token")).toBeTruthy();
expect(downloaded.suggestedFilename()).toBe(fileName);
const savedFile = testInfo.outputPath(fileName);
await downloaded.saveAs(savedFile);
expect(await downloaded.failure()).toBeNull();
expect(await readFile(savedFile)).toEqual(source);
});
@@ -1,7 +1,14 @@
import {
composerLocator,
enterComposerFullscreen,
exitComposerFullscreen,
resizeComposerViewport,
} from "../support/helpers/composer";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
import {
delayBrowserWorkspaceCreatedResponse,
expectNewWorkspaceDraft,
expectNewWorkspaceProjectSelected,
fillNewWorkspaceDraft,
@@ -134,3 +141,45 @@ test.describe("New workspace composer draft", () => {
}
});
});
test("fullscreen creation returns inline and keeps submission locked when reopened", async ({
page,
}) => {
const project = await seedWorkspace({ repoPrefix: "fullscreen-submit-pending-" });
const delay = await delayBrowserWorkspaceCreatedResponse(page);
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, project);
await resizeComposerViewport(page, "portrait");
await fillNewWorkspaceDraft(page, DRAFT);
await enterComposerFullscreen(page);
await page.getByRole("button", { name: "Create", exact: true }).click();
await delay.waitForCreateRequest();
await test.step("submission returns inline with the original draft and spinner", async () => {
await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0);
await expect(composerLocator(page)).toHaveValue(DRAFT);
await expect(composerLocator(page)).not.toBeEditable();
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
await expect(page.getByRole("progressbar")).toBeVisible();
});
await test.step("fullscreen can reopen while pending without unlocking or duplicating submission", async () => {
await enterComposerFullscreen(page);
await expect(page.getByTestId("composer-fullscreen")).toBeVisible();
await expect(composerLocator(page)).toHaveValue(DRAFT);
await expect(composerLocator(page)).not.toBeEditable();
await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled();
await expect(page.getByRole("progressbar")).toBeVisible();
await exitComposerFullscreen(page);
});
delay.release();
await page.waitForURL((url) => url.pathname.includes("/workspace/"));
await expect(composerLocator(page)).toHaveValue("");
await expect(composerLocator(page)).toBeEditable();
expect(delay.agentRequests).toHaveLength(1);
} finally {
delay.release();
await project.cleanup();
}
});
@@ -1,7 +1,18 @@
import { expect, test } from "../support/fixtures";
import { expect, test, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
import { openNewWorkspaceComposer } from "../support/helpers/new-workspace";
import {
expectNewWorkspaceDraft,
fillNewWorkspaceDraft,
openNewWorkspaceComposer,
} from "../support/helpers/new-workspace";
import {
composerLocator,
enterComposerFullscreen,
exitComposerFullscreen,
expectComposerFullscreenControls,
resizeComposerViewport,
} from "../support/helpers/composer";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectTerminalLaunchSelected,
@@ -145,3 +156,117 @@ test.describe("New workspace: launching a terminal", () => {
});
});
});
async function expectNewWorkspaceHeaderHidden(page: Page): Promise<void> {
await expect(page.getByTestId("menu-button").filter({ visible: true })).toHaveCount(0);
}
async function expectNewWorkspaceHeaderVisible(page: Page): Promise<void> {
await expect(page.getByTestId("menu-button").filter({ visible: true }).first()).toBeVisible();
}
/**
* Fullscreen fills the content area top to bottom and out to the right edge (a pinned
* sidebar keeps its column), covers the form, and shows only exit and the mode's submit.
*/
async function expectNewWorkspaceFullscreen(page: Page, submitLabel: string): Promise<void> {
const frame = page.getByTestId("composer-fullscreen");
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toBeVisible();
await expect(frame.getByRole("button")).toHaveCount(2);
await expect(frame.getByRole("button", { name: submitLabel })).toBeVisible();
await expectNewWorkspaceHeaderHidden(page);
const picker = (await page.getByRole("button", { name: "Workspace project" }).boundingBox())!;
await expect
.poll(() =>
frame.evaluate(
(element, point) => {
const viewport = window.visualViewport!;
const box = element.getBoundingClientRect();
const covering = document.elementFromPoint(point.x, point.y);
return {
fillsHeight:
Math.abs(box.top - viewport.offsetTop) < 1 &&
Math.abs(box.height - viewport.height) < 1,
reachesRightEdge:
Math.abs(box.right - (viewport.offsetLeft + viewport.width)) < 1 &&
box.left >= viewport.offsetLeft,
coversForm: element.contains(covering),
};
},
{ x: picker.x + picker.width / 2, y: picker.y + picker.height / 2 },
),
)
.toEqual({ fillsHeight: true, reachesRightEdge: true, coversForm: true });
}
test.describe("New workspace: fullscreen composer across the compact breakpoint", () => {
let workspace: SeededWorkspace;
test.beforeEach(async () => {
workspace = await seedWorkspace({ repoPrefix: "launch-fullscreen-" });
});
test.afterEach(async () => {
await workspace?.cleanup();
});
test("chat fullscreen survives widening and terminal fullscreen fills the phone", async ({
page,
}) => {
test.setTimeout(90_000);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
const projectPicker = page.getByRole("button", { name: "Workspace project" });
await test.step("chat: fullscreen covers the centered form and exits back into it", async () => {
await resizeComposerViewport(page, "portrait");
const draft = "Chat first line\nChat second line\nChat third line";
await fillNewWorkspaceDraft(page, draft);
await enterComposerFullscreen(page);
await expectComposerFullscreenControls(page);
await expectNewWorkspaceHeaderHidden(page);
await resizeComposerViewport(page, "landscape");
await expectComposerFullscreenControls(page);
await expectNewWorkspaceFullscreen(page, "Create");
// The desktop form's own import button is dropped from the fullscreen slot.
await expect(
page.getByTestId("new-workspace-import-session").filter({ visible: true }),
).toHaveCount(0);
await composerLocator(page).press("End");
await composerLocator(page).pressSequentially(" wide");
await exitComposerFullscreen(page);
await expectNewWorkspaceHeaderVisible(page);
await expect(projectPicker).toBeVisible();
await expect(
page.getByTestId("new-workspace-import-session").filter({ visible: true }),
).toBeVisible();
await expectNewWorkspaceDraft(page, `${draft} wide`);
});
// Terminal mode and its prompt are screen state, and the shell remounts the screen
// at the breakpoint, so the terminal prompt's fullscreen is exercised on the phone
// layout only.
await test.step("terminal: the prompt's fullscreen shows only exit and Launch", async () => {
await resizeComposerViewport(page, "portrait");
// The compact launch control opens a sheet, not the desktop menu.
await launchTrigger(page).click();
const option = page.getByRole("menuitem").filter({ hasText: PROMPT_PROFILE.name });
await option.click();
await expect(option).not.toBeVisible();
await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name);
const prompt = "Prompt first line\nPrompt second line\nPrompt third line";
await fillTerminalPrompt(page, prompt);
await enterComposerFullscreen(page);
await expect(terminalPromptInput(page)).toBeVisible();
await expectNewWorkspaceFullscreen(page, "Launch");
await exitComposerFullscreen(page);
await expectNewWorkspaceHeaderVisible(page);
await expect(projectPicker).toBeVisible();
await expect(terminalPromptInput(page)).toHaveValue(prompt);
});
});
});
@@ -0,0 +1,74 @@
import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
expectSidebarWorkspaceSelected,
switchWorkspaceViaSidebar,
} from "../support/helpers/workspace-ui";
async function readVisibleWorkspaceOrder(page: Page): Promise<string[]> {
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
await expect(rows).toHaveCount(12);
return rows.evaluateAll(
(elements, prefix) =>
elements.map((element) => element.getAttribute("data-testid")!.slice(prefix.length)),
`sidebar-workspace-row-${getServerId()}:`,
);
}
async function navigateWorkspace(
page: Page,
direction: "next" | "previous",
workspaceId: string,
): Promise<void> {
await page.keyboard.press(direction === "next" ? "Alt+]" : "Alt+[");
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId });
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}$`));
}
test("previous and next traverse every visible workspace while digit shortcuts stop at nine", async ({
page,
}) => {
test.setTimeout(120_000);
const project = await seedWorkspace({
repoPrefix: "relative-navigation-",
title: "Workspace 01",
});
try {
for (let index = 2; index <= 12; index += 1) {
const created = await project.client.createWorkspace({
source: { kind: "directory", path: project.repoPath },
title: `Workspace ${String(index).padStart(2, "0")}`,
});
expect(created.error).toBeNull();
}
await page.setViewportSize({ width: 1440, height: 1100 });
await gotoAppShell(page);
const order = await readVisibleWorkspaceOrder(page);
await test.step("next crosses the ninth workspace and previous returns to it", async () => {
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[8] });
await navigateWorkspace(page, "next", order[9]);
await navigateWorkspace(page, "previous", order[8]);
});
await test.step("wrapping uses the true first and last visible workspace", async () => {
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
await navigateWorkspace(page, "previous", order[11]);
await navigateWorkspace(page, "next", order[0]);
});
await test.step("the ninth digit shortcut still selects the ninth workspace", async () => {
await page.keyboard.press("Alt+9");
await expectSidebarWorkspaceSelected({
page,
serverId: getServerId(),
workspaceId: order[8],
});
});
} finally {
await project.cleanup();
}
});
@@ -87,10 +87,11 @@ export async function emitSettledAssistantImage(
client: SeedDaemonClient,
agent: ArchiveTabAgent,
image: AssistantImageFixture,
destination: string = image.relativePath,
): Promise<void> {
await client.sendAgentMessage(
agent.id,
`Emit settled assistant image Markdown: ![${image.alt}](${image.relativePath})`,
`Emit settled assistant image Markdown: ![${image.alt}](${destination})`,
);
const result = await client.waitForFinish(agent.id, 30_000);
if (result.status !== "idle" || result.final?.lastError) {
@@ -56,6 +56,12 @@ export async function expectComposerNotFocused(page: Page): Promise<void> {
await expect(composerInput(page)).not.toBeFocused();
}
/** Blur the editor without anything else taking focus. */
export async function blurComposerInput(page: Page): Promise<void> {
await composerInput(page).blur();
await expectComposerNotFocused(page);
}
export async function submitMessage(page: Page, text: string): Promise<void> {
const input = composerInput(page);
await expect(input).toBeEditable({ timeout: 30_000 });
@@ -355,6 +361,12 @@ export async function resizeComposerViewport(
short: { width: 390, height: 390 },
};
await page.setViewportSize(sizes[orientation]);
// Before the app has loaded there is no layout to wait for.
if (page.url() === "about:blank") return;
// Crossing the compact breakpoint remounts the shell and the editor. Anything that
// follows (a shortcut, a locator) belongs to the layout the new size implies, so
// wait for that layout before returning.
await expect(page.getByTestId("compact-shell")).toHaveCount(orientation === "landscape" ? 0 : 1);
}
export async function selectComposerDraftRange(
page: Page,
@@ -390,6 +402,33 @@ export async function expectComposerFullscreenControls(page: Page): Promise<void
await expect(page.getByTestId("composer-fullscreen").getByRole("button")).toHaveCount(2);
await expect(composerInput(page)).toHaveCount(1);
}
/** The fullscreen frame and its submit button sit inside the browser's visible viewport. */
export async function expectComposerFullscreenFitsVisibleViewport(
page: Page,
submitLabel = "Send message",
): Promise<void> {
await expect
.poll(() =>
page.getByTestId("composer-fullscreen").evaluate((element, label) => {
const viewport = window.visualViewport!;
const frame = element.getBoundingClientRect();
const send = element.querySelector(`[aria-label="${label}"]`)!.getBoundingClientRect();
return {
frameFits:
Math.abs(frame.top - viewport.offsetTop) < 1 &&
Math.abs(frame.left - viewport.offsetLeft) < 1 &&
Math.abs(frame.height - viewport.height) < 1 &&
Math.abs(frame.width - viewport.width) < 1,
sendFits:
send.top >= viewport.offsetTop &&
send.bottom <= viewport.offsetTop + viewport.height &&
send.left >= viewport.offsetLeft &&
send.right <= viewport.offsetLeft + viewport.width,
};
}, submitLabel),
)
.toEqual({ frameFits: true, sendFits: true });
}
export async function sendComposerWithButton(page: Page): Promise<void> {
await page.getByRole("button", { name: "Send message", exact: true }).click();
}
@@ -405,6 +444,22 @@ export async function configureComposerQueueSend(page: Page): Promise<void> {
/** Drive the real tab chooser's commands even when fullscreen covers its pointer surface.
* This exercises retained activity changes, not a claim that covered chrome is clickable.
*/
/** Needs a desktop-width viewport: compact layouts have no keyboard shortcuts. */
export async function switchComposerTabWithKeyboard(
page: Page,
direction: "previous" | "next",
): Promise<void> {
await page.keyboard.press(
direction === "previous" ? "Alt+Shift+BracketLeft" : "Alt+Shift+BracketRight",
);
}
export async function expectWorkspaceHeaderHidden(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toHaveCount(0);
}
export async function expectWorkspaceHeaderVisible(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toBeVisible();
}
export async function activateRetainedComposerTab(page: Page, title: string): Promise<void> {
await page
.getByTestId("workspace-tab-switcher-trigger")
@@ -0,0 +1,37 @@
import { expect, type Page } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
import { openSettingsHostSection } from "./settings";
import { getServerId } from "./server-id";
export async function openCustomModels(page: Page): Promise<void> {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, getServerId(), "providers");
await page.getByRole("button", { name: "Claude provider details", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Search models" })).toBeVisible();
}
export async function addCustomModel(page: Page, id: string): Promise<void> {
await page.getByRole("button", { name: "Add model", exact: true }).click();
await page.getByPlaceholder("e.g. openai/gpt-5").fill(id);
await page
.getByTestId("add-custom-model-sheet")
.getByRole("button", { name: "Add", exact: true })
.click();
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1);
}
export async function removeCustomModel(page: Page, id: string): Promise<void> {
await page.getByRole("textbox", { name: "Search models" }).fill(id);
const remove = page.getByRole("button", { name: `Remove ${id}`, exact: true });
await expect(remove).toBeInViewport({ ratio: 1 });
await remove.click();
await expect(remove).toHaveCount(0);
}
export async function expectCustomModelRetained(page: Page, id: string): Promise<void> {
await page.getByRole("textbox", { name: "Search models" }).fill(id);
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toBeInViewport({
ratio: 1,
});
}
@@ -1,4 +1,4 @@
import { expect, type Page } from "@playwright/test";
import { expect, type Download, type Page } from "@playwright/test";
import { openFilesPanel } from "./workspace-tabs";
function fileExplorerTree(page: Page) {
@@ -39,3 +39,10 @@ export async function expectFileTabOpen(page: Page, filePath: string): Promise<v
timeout: 30_000,
});
}
export async function downloadFileFromExplorer(page: Page, fileName: string): Promise<Download> {
await fileExplorerEntry(page, fileName).click({ button: "right" });
const downloaded = page.waitForEvent("download");
await page.getByRole("menuitem", { name: "Download", exact: true }).click();
return downloaded;
}
@@ -1,31 +1,37 @@
import { expect, type Locator, type Page } from "@playwright/test";
/** The compact workspace sheet, named after the tab it hosts. */
export function workspaceSheet(page: Page, label: string): Locator {
/** A sheet tab's body, named after the tab it shows. */
export function sheet(page: Page, label: string): Locator {
return page.getByRole("dialog", { name: label });
}
/** Sheet height as a fraction of the viewport; mirrors the host's 92% snap point. */
/** A sheet tab's header, which carries its title, Close, and the Open as tab glyph. */
export function sheetHeader(page: Page, label: string): Locator {
return page.getByTestId("sheet-tab-header").filter({ hasText: label });
}
/** Sheet height as a fraction of the viewport; mirrors the sheet tab's 92% snap point. */
const SHEET_SNAP_FRACTION = 0.92;
/** The dialog starts below the sheet's drag handle. */
const SHEET_HANDLE_MAX_HEIGHT = 32;
/** The header starts below the sheet's drag indicator, inside the handle. */
const SHEET_INDICATOR_MAX_HEIGHT = 32;
/**
* The sheet rests at its snap point, docked to the bottom edge. A sheet pushed up past it
* covers the workspace header; a sheet sunk below it hides content.
*/
export async function expectWorkspaceSheetAtSnap(page: Page, label: string): Promise<void> {
export async function expectSheetAtSnap(page: Page, label: string): Promise<void> {
const viewportHeight = page.viewportSize()?.height ?? 0;
const snapTop = Math.round(viewportHeight * (1 - SHEET_SNAP_FRACTION));
const sheet = workspaceSheet(page, label);
const header = sheetHeader(page, label);
const body = sheet(page, label);
await expect
.poll(async () => {
const box = await sheet.boundingBox();
if (!box) return null;
const top = Math.round(box.y);
const [headerBox, bodyBox] = await Promise.all([header.boundingBox(), body.boundingBox()]);
if (!headerBox || !bodyBox) return null;
const top = Math.round(headerBox.y);
return {
dockedToBottom: Math.round(box.y + box.height) === viewportHeight,
topAtSnap: top >= snapTop && top <= snapTop + SHEET_HANDLE_MAX_HEIGHT,
dockedToBottom: Math.round(bodyBox.y + bodyBox.height) === viewportHeight,
topAtSnap: top >= snapTop && top <= snapTop + SHEET_INDICATOR_MAX_HEIGHT,
};
})
.toEqual({ dockedToBottom: true, topAtSnap: true });
@@ -37,26 +43,22 @@ export async function expectOpenTabCount(page: Page, count: number): Promise<voi
).toBeVisible();
}
export async function closeWorkspaceSheet(page: Page, label: string): Promise<void> {
const sheet = workspaceSheet(page, label);
await sheet.getByRole("button", { name: "Close" }).click();
await expect(sheet).toHaveCount(0);
export async function closeSheet(page: Page, label: string): Promise<void> {
await sheetHeader(page, label).getByRole("button", { name: "Close" }).click();
await expect(sheet(page, label)).toHaveCount(0);
}
export async function openWorkspaceSheetAsTab(page: Page, label: string): Promise<void> {
const sheet = workspaceSheet(page, label);
await sheet.getByRole("button", { name: "Open as tab" }).click();
await expect(sheet).toHaveCount(0);
export async function openSheetAsTab(page: Page, label: string): Promise<void> {
await sheetHeader(page, label).getByRole("button", { name: "Open as tab" }).click();
await expect(sheet(page, label)).toHaveCount(0);
}
export async function replaceWorkspaceSheetFileText(
export async function replaceSheetFileText(
page: Page,
label: string,
content: string,
): Promise<void> {
const editor = workspaceSheet(page, label)
.getByTestId("file-source-editor")
.locator(".cm-content");
const editor = sheet(page, label).getByTestId("file-source-editor").locator(".cm-content");
await editor.click();
await editor.press("ControlOrMeta+A");
await editor.pressSequentially(content);
@@ -67,15 +69,18 @@ export async function selectTabFromSwitcher(page: Page, label: string): Promise<
.getByRole("button", { name: /Switch tabs/ })
.filter({ visible: true })
.click();
await expect(page.getByText("Workspace tabs", { exact: true })).toBeVisible();
const option = page.getByText(label, { exact: true }).filter({ visible: true }).last();
await expect(option).toBeVisible();
await option.click();
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
}
export async function tapWorkspaceSheetBackdrop(page: Page): Promise<void> {
/** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */
export async function tapSheetBackdrop(page: Page): Promise<void> {
await page
.getByRole("button", { name: "Bottom sheet backdrop" })
.last()
.click({ position: { x: 10, y: 10 } });
}
@@ -250,3 +250,41 @@ export async function expectMobileAgentSidebarVisible(page: Page): Promise<void>
export async function expectMobileAgentSidebarHidden(page: Page): Promise<void> {
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
}
/** Drag from the left edge: the only way to open the list while fullscreen hides the header. */
export async function swipeOpenMobileAgentSidebar(page: Page): Promise<void> {
const viewport = page.viewportSize();
if (!viewport) throw new Error("viewport size is required");
const y = Math.round(viewport.height / 2);
await page.mouse.move(8, y);
await page.mouse.down();
await page.mouse.move(40, y, { steps: 4 });
await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 });
await page.mouse.up();
}
/** Drag from the right edge to open the compact Explorer over the agent. */
export async function swipeOpenMobileExplorer(page: Page): Promise<void> {
const viewport = page.viewportSize();
if (!viewport) throw new Error("viewport size is required");
const y = Math.round(viewport.height / 2);
await page.mouse.move(viewport.width - 8, y);
await page.mouse.down();
await page.mouse.move(viewport.width - 40, y, { steps: 4 });
await page.mouse.move(Math.round(viewport.width * 0.3), y, { steps: 12 });
await page.mouse.up();
}
export async function closeMobileExplorer(page: Page): Promise<void> {
const closeButton = page.getByTestId("explorer-close");
await expect(closeButton).toBeInViewport({ ratio: 1, timeout: 5_000 });
await closeButton.click();
}
export async function expectMobileExplorerVisible(page: Page): Promise<void> {
await expect(page.getByTestId("explorer-header")).toBeInViewport({ ratio: 1, timeout: 5_000 });
}
export async function expectMobileExplorerHidden(page: Page): Promise<void> {
await expect(page.getByTestId("explorer-header")).not.toBeInViewport({ timeout: 5_000 });
}
+1 -1
View File
@@ -665,7 +665,7 @@ function MobileGestureWrapper({
return (
<GestureDetector gesture={openGesture} touchAction={MOBILE_WEB_GESTURE_TOUCH_ACTION}>
<View collapsable={false} style={layoutStyles.surfaceFill}>
<View collapsable={false} style={layoutStyles.surfaceFill} testID="compact-shell">
{children}
</View>
</GestureDetector>
+29 -8
View File
@@ -1,6 +1,15 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import {
ComposerEditingScope,
WorkspaceComposerEditingSessionsProvider,
} from "@/composer/editing-session";
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
import { buildNewWorkspaceDraftKey } from "@/stores/draft-keys";
/** New workspace drafts share one editing-session home, keyed like a tab by draft. */
const NEW_WORKSPACE_SESSIONS_KEY = "new-workspace";
export default function NewWorkspaceRoute() {
const params = useLocalSearchParams<{
@@ -23,16 +32,28 @@ export default function NewWorkspaceRoute() {
draftId ?? null,
]);
// The shell remounts this route when the viewport crosses the compact breakpoint;
// the draft's editing session (fullscreen, retained caret) lives above the shell.
const draftKey = buildNewWorkspaceDraftKey(draftId);
const sessionTabIds = useMemo(() => [draftKey], [draftKey]);
return (
<HostRouteBootstrapBoundary>
<NewWorkspaceScreen
key={screenKey}
serverId={serverId}
sourceDirectory={sourceDirectory}
displayName={displayName}
projectId={projectId}
draftId={draftId}
/>
<WorkspaceComposerEditingSessionsProvider
workspaceKey={NEW_WORKSPACE_SESSIONS_KEY}
tabIds={sessionTabIds}
>
<ComposerEditingScope tabId={draftKey} targetKey={draftKey}>
<NewWorkspaceScreen
key={screenKey}
serverId={serverId}
sourceDirectory={sourceDirectory}
displayName={displayName}
projectId={projectId}
draftId={draftId}
/>
</ComposerEditingScope>
</WorkspaceComposerEditingSessionsProvider>
</HostRouteBootstrapBoundary>
);
}
@@ -42,6 +42,18 @@ describe("fileUriToPath", () => {
});
describe("localFileSourceToPath", () => {
it.each([
"https://example.com/image%20name.png",
"data:image/png;base64,abc%20",
"blob:https://example.com/image%20name",
])("preserves a direct Markdown image URI: %s", (source) => {
expect(localFileSourceToPath(source, { decodeHref: true })).toBe(source);
});
it("preserves malformed percent escapes rather than throwing", () => {
expect(localFileSourceToPath("/tmp/100%.png", { decodeHref: true })).toBe("/tmp/100%.png");
});
it("decodes markdown-encoded Windows drive-letter paths", () => {
expect(localFileSourceToPath("C:%5CUsers%5Cfile.txt")).toBe("C:/Users/file.txt");
});
+9 -2
View File
@@ -173,11 +173,18 @@ export function fileUriToPath(uri: string): string {
return normalizeWindowsDrivePath(decodedPath.replace(/^\/([A-Za-z]:[\\/])/, "$1"));
}
export function localFileSourceToPath(source: string): string {
export function localFileSourceToPath(
source: string,
// Markdown destinations are URI-encoded; plain filesystem paths are not.
{ decodeHref = false }: { decodeHref?: boolean } = {},
): string {
if (/^(https?:|data:|blob:)/i.test(source)) {
return source;
}
let path = source;
if (source.startsWith("file://")) {
path = fileUriToPath(source);
} else if (isMarkdownEncodedWindowsDrivePath(source)) {
} else if (decodeHref || isMarkdownEncodedWindowsDrivePath(source)) {
path = decodeFilePathSource(source);
}
return normalizeWindowsDrivePath(path);
@@ -1,10 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import type { ComponentProps, ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Keyboard, Pressable, Text, View } from "react-native";
import type { DimensionValue, StyleProp, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
getOverlayRoot,
@@ -12,9 +12,9 @@ import {
useGlobalWebOverlayLayer,
useWebOverlayRegistration,
} from "../lib/overlay-root";
import { type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import { BottomSheetHandle, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import Animated from "react-native-reanimated";
import { ArrowLeft, Search, X } from "lucide-react-native";
import { ArrowLeft, Search, X, type LucideIcon } from "lucide-react-native";
import {
IsolatedBottomSheetModal,
SheetVisibleFrame,
@@ -223,7 +223,8 @@ const styles = StyleSheet.create((theme) => ({
const WEB_EXIT_DURATION_MS = 160;
function SheetBackground({ style }: BottomSheetBackgroundProps) {
/** The compact sheet's surface: Gorhom's `backgroundComponent` for every bottom sheet. */
export function SheetBackground({ style }: BottomSheetBackgroundProps) {
const { theme } = useUnistyles();
const combinedStyle = useMemo(
() => [
@@ -239,6 +240,39 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
return <Animated.View pointerEvents="none" style={combinedStyle} />;
}
const ThemedBottomSheetHandle = withUnistyles(BottomSheetHandle, (theme) => ({
indicatorStyle: { backgroundColor: theme.colors.palette.zinc[600] },
}));
const HANDLE_WITH_CONTENT_STYLE = { paddingHorizontal: 0, paddingBottom: 0 };
/**
* The compact sheet's drag handle: Gorhom's `handleComponent` for every bottom sheet. Children
* join the handle, so a header passed here drags the sheet like the indicator does. That is how a
* sheet whose content scrolls on its own keeps its header draggable without content panning.
*/
export function SheetHandle({
children,
// Gorhom passes `indicatorStyle={undefined}`, which would override the themed indicator.
indicatorStyle: _indicatorStyle,
...props
}: ComponentProps<typeof BottomSheetHandle>) {
if (!children) return <ThemedBottomSheetHandle {...props} />;
// The children carry their own semantics; the handle's slider role would swallow them on web.
return (
<ThemedBottomSheetHandle
{...props}
style={HANDLE_WITH_CONTENT_STYLE}
accessible={false}
accessibilityRole={null}
accessibilityLabel={null}
accessibilityHint={null}
>
{children}
</ThemedBottomSheetHandle>
);
}
/**
* The sheet body, indented to the sheet's content inset.
*
@@ -253,6 +287,37 @@ function SheetContent({ style, children }: { style: StyleProp<ViewStyle>; childr
return <View style={[styles.sheetContent, style]}>{children}</View>;
}
/**
* An icon action in a sheet header's trailing rail, drawn like the Close button so a header's
* actions line up with it at one size. Pass it through `SheetHeader.actions`.
*/
export function SheetHeaderIconButton({
icon: Icon,
accessibilityLabel,
onPress,
testID,
}: {
icon: LucideIcon;
accessibilityLabel: string;
onPress: () => void;
testID?: string;
}) {
const { theme } = useUnistyles();
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
style={styles.closeButton}
onPress={onPress}
testID={testID}
>
{({ pressed }) => (
<Icon size={16} color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted} />
)}
</Pressable>
);
}
export function SheetHeaderView({
header,
onClose,
@@ -309,19 +374,11 @@ export function SheetHeaderView({
</View>
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
{showCloseButton ? (
<Pressable
accessibilityRole="button"
<SheetHeaderIconButton
icon={X}
accessibilityLabel={t("common.actions.close")}
style={styles.closeButton}
onPress={onClose}
>
{({ pressed }) => (
<X
size={16}
color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
/>
) : null}
</View>
{search ? (
@@ -449,7 +506,6 @@ export function AdaptiveModalSheet({
bodyStyle,
contextBridge = null,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isMobile = useIsCompactFormFactor();
const insets = useSafeAreaInsets();
@@ -481,10 +537,6 @@ export function AdaptiveModalSheet({
) : null,
[footer, footerClearanceStyle, footerContainerStyle],
);
const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
[theme.colors.palette.zinc],
);
useEffect(() => {
if (!isWeb && visible) {
// A newly opened sheet owns input. A keyboard belonging to the sheet below
@@ -600,7 +652,7 @@ export function AdaptiveModalSheet({
backdropOpacity={0.45}
enablePanDownToClose
backgroundComponent={SheetBackground}
handleIndicatorStyle={handleIndicatorStyle}
handleComponent={SheetHandle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
+13 -38
View File
@@ -35,6 +35,7 @@ import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { DensityIcon } from "@/components/ui/density-icon";
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
@@ -805,21 +806,18 @@ function DesktopSidebar({
function WorkspacesSectionHeader() {
return (
<View style={styles.workspacesSectionHeader}>
<Text style={styles.workspacesSectionTitle}>Workspaces</Text>
<View style={styles.workspacesSectionActions}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<View>
<SidebarDisplayPreferencesMenu />
</View>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<IconTooltipContent label="Display preferences" />
</TooltipContent>
</Tooltip>
</View>
</View>
<SidebarSectionHeader title="Workspaces">
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<View>
<SidebarDisplayPreferencesMenu />
</View>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<IconTooltipContent label="Display preferences" />
</TooltipContent>
</Tooltip>
</SidebarSectionHeader>
);
}
@@ -850,29 +848,6 @@ const styles = StyleSheet.create((theme) => ({
sidebarHeaderGroupBelowChrome: {
paddingTop: 0,
},
workspacesSectionHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
// Rendered inside the scroll's listContent (paddingHorizontal spacing[2]). The title
// lands at spacing[2] left to align with project icons. Settings2's painted path stops
// inside its 14px SVG, so 4px aligns the ink rather than the SVG box to the row rail.
paddingLeft: theme.spacing[2],
paddingRight: 4,
paddingTop: theme.spacing[1],
paddingBottom: theme.spacing[1],
},
workspacesSectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
workspacesSectionActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
sidebarContent: {
flex: 1,
minHeight: 0,
@@ -3,6 +3,71 @@ import { describe, expect, it } from "vitest";
import { normalizeHtmlishMarkdown, splitHtmlishMarkdown } from "./html-ish";
describe("splitHtmlishMarkdown", () => {
it("renders heading text beside a linked picture without literal heading tags", () => {
const source =
'<h2><a href="https://example.com/r"><picture><source srcset="https://example.com/d.svg"><img alt="Retrigger" src="https://example.com/l.svg"></picture></a>Confidence Score: 5/5</h2>';
expect(splitHtmlishMarkdown(source)).toEqual([
{
kind: "inlineImage",
alt: "Retrigger",
src: "https://example.com/l.svg",
href: "https://example.com/r",
flowsWithText: true,
},
{ kind: "markdown", text: "Confidence Score: 5/5" },
]);
});
it.each(["h1", "h2", "h3", "h4", "h5", "h6"])(
"keeps a mixed %s heading together after preceding markdown",
(heading) => {
const source = `Intro\n\n<${heading}><img alt="Icon" src="https://example.com/icon.svg">Caption</${heading}>\n\nOutro`;
expect(splitHtmlishMarkdown(source)).toEqual([
{ kind: "markdown", text: "Intro\n\n" },
{
kind: "inlineImage",
alt: "Icon",
src: "https://example.com/icon.svg",
flowsWithText: true,
},
{ kind: "markdown", text: "Caption\n\nOutro" },
]);
},
);
it("preserves formatted text before and after images in a details heading", () => {
const source =
'<details><summary>More</summary><h2><strong>Before</strong><img alt="Icon" src="https://example.com/icon.svg"><em>After</em></h2></details>';
expect(splitHtmlishMarkdown(source)).toEqual([
{
kind: "details",
summary: "More",
body: "**Before***After*",
bodyParts: [
{ kind: "markdown", text: "**Before**" },
{ kind: "inlineImage", alt: "Icon", src: "https://example.com/icon.svg" },
{ kind: "markdown", text: "*After*" },
],
},
]);
});
it("keeps mixed HTML headings literal in code examples", () => {
const heading = '<h2><img src="https://example.com/icon.svg">Caption</h2>';
const source = `Use \`${heading}\`\n\n\`\`\`html\n${heading}\n\`\`\``;
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
});
it("preserves unknown wrappers around text-only headings", () => {
expect(splitHtmlishMarkdown("<section><h2>Heading</h2></section>")).toEqual([
{ kind: "markdown", text: "<section>Heading</section>" },
]);
});
const inlineImageBody = [
'<a href="#"><img alt="Priority" src="https://example.com/assets/priority.svg?v=9" align="top"></a> Spoofed browser User-Agent allows access control bypass',
"",
@@ -125,9 +125,20 @@ function splitHtmlishTokens(tokens: HtmlToken[]): MarkdownDisplayPart[] {
continue;
}
const nextDetailsIndex = findNextOpenTag(tokens, cursor + 1, "details");
const nextInlineImageIndex = findNextInlineImageIndex(tokens, cursor + 1);
const end = Math.min(nextDetailsIndex ?? tokens.length, nextInlineImageIndex ?? tokens.length);
if (isHeadingTag(token)) {
const closeIndex = findMixedHeadingClose(tokens, cursor);
if (closeIndex !== null) {
// Headings render as their contents. Split those contents together so an
// embedded image cannot strand the opening heading tag in a text slice.
for (const part of splitHtmlishTokens(tokens.slice(cursor + 1, closeIndex))) {
appendDisplayPart(parts, part);
}
cursor = closeIndex + 1;
continue;
}
}
const end = findNextDisplayPartIndex(tokens, cursor + 1) ?? tokens.length;
appendMarkdownPart(parts, renderInlineTokens(tokens.slice(cursor, end)));
cursor = end;
}
@@ -259,15 +270,44 @@ function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number)
return false;
}
function findNextInlineImageIndex(tokens: HtmlToken[], start: number): number | null {
function findNextDisplayPartIndex(tokens: HtmlToken[], start: number): number | null {
for (let index = start; index < tokens.length; index += 1) {
if (parseInlineImageAt(tokens, index)) {
if (
isOpenTag(tokens[index], "details") ||
findMixedHeadingClose(tokens, index) !== null ||
parseInlineImageAt(tokens, index)
) {
return index;
}
}
return null;
}
function findMixedHeadingClose(tokens: HtmlToken[], start: number): number | null {
const token = tokens[start];
if (!isHeadingTag(token)) {
return null;
}
const closeIndex = findMatchingClose(tokens, start, token.name);
if (closeIndex === null) {
return null;
}
for (let index = start + 1; index < closeIndex; index += 1) {
if (parseInlineImageAt(tokens, index)) {
return closeIndex;
}
}
return null;
}
function appendDisplayPart(parts: MarkdownDisplayPart[], part: MarkdownDisplayPart): void {
if (part.kind === "markdown") {
appendMarkdownPart(parts, part.text);
} else {
parts.push(part);
}
}
function appendMarkdownPart(parts: MarkdownDisplayPart[], text: string): void {
if (!text) {
return;
+2 -1
View File
@@ -95,6 +95,7 @@ import {
} from "@/assistant-file-links";
import { getCompactionMarkerLabel } from "./message-compaction-label";
import { useAssistantImage } from "@/assistant-image/use-assistant-image";
import { localFileSourceToPath } from "@/attachments/utils";
import {
AttachmentFrame,
AttachmentLabel,
@@ -1945,7 +1946,7 @@ export const AssistantMessage = memo(function AssistantMessage({
return (
<AssistantMarkdownImage
key={node.key}
source={String(node.attributes?.src ?? "")}
source={localFileSourceToPath(String(node.attributes?.src ?? ""), { decodeHref: true })}
occurrenceKey={`${occurrenceKey}:${node.key}`}
alt={typeof node.attributes?.alt === "string" ? node.attributes.alt : undefined}
hasLeadingContent={hasLeadingContent}
@@ -95,11 +95,11 @@ function CustomModelRow({
return (
<View style={sheetStyles.modelRow}>
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
<Text style={[sheetStyles.modelTitle, sheetStyles.customModelText]} numberOfLines={1}>
{model.label}
</Text>
<Text
style={sheetStyles.monoHint}
style={[sheetStyles.monoHint, sheetStyles.customModelText]}
numberOfLines={1}
selectable
dataSet={CODE_SURFACE_DATASET}
@@ -762,6 +762,7 @@ const sheetStyles = StyleSheet.create((theme) => ({
iconButton: {
width: 28,
height: 28,
flexShrink: 0,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
@@ -807,6 +808,10 @@ const sheetStyles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
flexShrink: 0,
},
customModelText: {
flexShrink: 1,
minWidth: 0,
},
modelRowFiller: {
flex: 1,
},
@@ -30,7 +30,7 @@ import {
type ActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import { density, densityIconBox } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
@@ -93,7 +93,7 @@ import {
SidebarWorkspaceMenu,
} from "@/components/sidebar/sidebar-workspace-menu";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
import {
@@ -2114,6 +2114,7 @@ function ProjectModeList({
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
}) {
const { t } = useTranslation();
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
@@ -2413,7 +2414,12 @@ function ProjectModeList({
<>
{pinnedChats.length > 0 ? (
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
<SidebarSectionHeader
title={t("sidebar.pinned.title")}
collapsed={pinnedCollapsed}
onToggle={togglePinnedCollapsed}
testID="sidebar-pinned-section-header"
/>
{pinnedCollapsed ? null : (
<>
<DraggableList
@@ -2550,12 +2556,12 @@ const styles = StyleSheet.create((theme) => ({
},
newWorkspaceGhostText: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
},
newWorkspaceGhostTextHovered: {
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
color: theme.colors.foreground,
@@ -2603,7 +2609,7 @@ const styles = StyleSheet.create((theme) => ({
},
projectTitle: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
@@ -1,62 +0,0 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight } from "lucide-react-native";
import { Pressable, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { DensityIcon } from "@/components/ui/density-icon";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { density, densityFontSize } from "@/styles/density";
export function PinnedSectionHeader({
collapsed,
onToggle,
}: {
collapsed: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={onToggle}
style={styles.header}
testID="sidebar-pinned-section-header"
>
{({ hovered }) => (
<>
<Text style={styles.title}>{t("sidebar.pinned.title")}</Text>
{hovered || isNative || isCompact ? (
<DensityIcon
icon={collapsed ? ChevronRight : ChevronDown}
size="xs"
color="foregroundMuted"
/>
) : null}
</>
)}
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
header: {
minHeight: density.rowHeight,
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
userSelect: "none",
},
title: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "sm"),
fontWeight: theme.fontWeight.normal,
},
}));
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model";
import { density, densityIconBox, densityIconProps } from "@/styles/density";
import { densityIconBox, densityIconProps } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import {
@@ -24,6 +24,9 @@ import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
// Every surfaced status shares one badge shell, so the badge never changes size or position
// between states. Only the thing inside it changes.
// Matches the workspace title's lineHeight (sidebar-workspace-row-content's
// workspaceBranchText) so the icon centers on the title rather than floating above it.
const LEADING_SLOT_HEIGHT = 20;
const STATUS_BADGE_SIZE = 12;
// How far the badge overhangs the icon's bottom-right corner. Well short of half the badge:
// centered on the corner it reads as hanging off the icon rather than sitting on it, and the
@@ -273,10 +276,10 @@ const styles = StyleSheet.create((theme) => {
// The slot is as tall as the title's line box, not as tall as the icon, and centers the
// icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a slot
// only as tall as the icon puts it above the title and the kebab — which is why the
// workspace status indicator shares the same `rowLineHeight`.
// workspace status indicator is as tall as `LEADING_SLOT_HEIGHT`.
projectLeadingVisualSlot: {
width: densityIconBox("md"),
height: density.rowLineHeight,
height: LEADING_SLOT_HEIGHT,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
@@ -6,7 +6,7 @@ import { StyleSheet } from "react-native-unistyles";
import { isWeb } from "@/constants/platform";
import { sidebarWorkspaceRowStyles } from "@/components/sidebar/sidebar-workspace-row-content";
import { DensityIcon } from "@/components/ui/density-icon";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import { density, densityIconBox } from "@/styles/density";
/**
* The row that ends a truncated group. It is a workspace row that happens to say "Show more", so
@@ -100,13 +100,13 @@ const styles = StyleSheet.create((theme) => ({
},
text: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
},
textHovered: {
color: theme.colors.foreground,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
},
@@ -4,7 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
import { DensityIcon } from "@/components/ui/density-icon";
import { Shortcut } from "@/components/ui/shortcut";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import { density, densityIconBox } from "@/styles/density";
import type { ShortcutKey } from "@/utils/format-shortcut";
type SidebarHeaderRowVariant = "header" | "compact" | "inline";
@@ -152,7 +152,7 @@ const styles = StyleSheet.create((theme) => ({
iconSpacerCompact: { width: densityIconBox("sm"), height: densityIconBox("sm") },
label: {
flexShrink: 1,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
@@ -0,0 +1,115 @@
import { useMemo, type ReactNode } from "react";
import { ChevronDown, ChevronRight } from "lucide-react-native";
import { Pressable, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { DensityIcon } from "@/components/ui/density-icon";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { density } from "@/styles/density";
/** A sidebar section label such as "Pinned" or "Workspaces". A section with `onToggle`
* collapses from its title; children sit at the trailing edge as its actions. */
export function SidebarSectionHeader({
title,
collapsed,
onToggle,
testID,
children,
}: {
title: string;
collapsed?: boolean;
onToggle?: () => void;
testID?: string;
children?: ReactNode;
}) {
return (
<View style={styles.header}>
{onToggle ? (
<CollapsibleTitle
title={title}
collapsed={collapsed ?? false}
onToggle={onToggle}
testID={testID}
/>
) : (
<Text style={styles.title} testID={testID}>
{title}
</Text>
)}
{children ? <View style={styles.actions}>{children}</View> : null}
</View>
);
}
function CollapsibleTitle({
title,
collapsed,
onToggle,
testID,
}: {
title: string;
collapsed: boolean;
onToggle: () => void;
testID?: string;
}) {
const isCompact = useIsCompactFormFactor();
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
onPress={onToggle}
style={styles.toggle}
testID={testID}
>
{({ hovered }) => (
<>
<Text style={styles.title}>{title}</Text>
{hovered || isNative || isCompact ? (
<DensityIcon
icon={collapsed ? ChevronRight : ChevronDown}
size="xs"
color="foregroundMuted"
/>
) : null}
</>
)}
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
header: {
minHeight: density.rowHeight,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
// The title lands at spacing[2] to align with the rows' leading icons. Trailing actions
// are icon buttons whose painted glyph stops inside the SVG box, so 4px aligns the ink
// rather than the box to the row rail.
paddingLeft: theme.spacing[2],
paddingRight: 4,
userSelect: "none",
},
// The toggle reaches back over the header's leading padding so the whole label edge is pressable.
toggle: {
alignSelf: "stretch",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
marginLeft: -theme.spacing[2],
paddingHorizontal: theme.spacing[2],
},
title: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
actions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
}));
@@ -28,7 +28,7 @@ import type { HostBadgeModel } from "@/hosts/appearance";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { StyleSheet } from "react-native-unistyles";
import { density, densityFontSize, densityIconBox, densityIconProps } from "@/styles/density";
import { density, densityIconBox, densityIconProps } from "@/styles/density";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { withUnistyles } from "react-native-unistyles";
import {
@@ -73,7 +73,7 @@ import {
SidebarWorkspaceContextMenu,
SidebarWorkspaceMenu,
} from "@/components/sidebar/sidebar-workspace-menu";
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin";
@@ -146,6 +146,7 @@ export function SidebarStatusWorkspaceList({
parentGestureRef,
dragGestureHostActive,
}: StatusWorkspaceListProps) {
const { t } = useTranslation();
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedWorkspaceGroupKeys,
);
@@ -202,7 +203,12 @@ export function SidebarStatusWorkspaceList({
<>
{pinnedWorkspaces.length > 0 ? (
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
<SidebarSectionHeader
title={t("sidebar.pinned.title")}
collapsed={pinnedCollapsed}
onToggle={togglePinnedCollapsed}
testID="sidebar-pinned-section-header"
/>
{pinnedCollapsed ? null : (
<>
<DraggableList
@@ -1099,7 +1105,7 @@ const styles = StyleSheet.create((theme) => ({
},
statusGroupTitle: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
@@ -16,7 +16,7 @@ import {
type SidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
import { useAppSettings } from "@/hooks/use-settings";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import { densityIconBox } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor } from "@/utils/status-dot-color";
@@ -453,6 +453,9 @@ export function SidebarWorkspaceTrailingActionOverlay({
);
}
// The status dot centres on the title's first line, so both share its line height.
const TITLE_LINE_HEIGHT = 20;
const styles = StyleSheet.create((theme) => ({
workspaceRowContent: {
position: "relative",
@@ -481,7 +484,7 @@ const styles = StyleSheet.create((theme) => ({
workspaceStatusDot: {
position: "relative",
width: densityIconBox("md"),
height: density.rowLineHeight,
height: TITLE_LINE_HEIGHT,
borderRadius: theme.borderRadius.full,
flexShrink: 0,
alignItems: "center",
@@ -513,9 +516,9 @@ const styles = StyleSheet.create((theme) => ({
// to the meta row, so it takes the full width the trailing slot leaves behind.
workspaceBranchText: {
color: theme.colors.foreground,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: "400",
lineHeight: density.rowLineHeight,
lineHeight: TITLE_LINE_HEIGHT,
opacity: 0.76,
flex: 1,
minWidth: 0,
@@ -0,0 +1,91 @@
import {
BottomSheetModalInternalProvider,
INITIAL_CONTAINER_LAYOUT,
useBottomSheetModalInternal,
} from "@gorhom/bottom-sheet";
import { PortalHost } from "@gorhom/portal";
import { useCallback, useId, useMemo, useRef, type ReactNode } from "react";
import {
Dimensions,
StatusBar,
StyleSheet,
View,
type LayoutChangeEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
import { useSharedValue } from "react-native-reanimated";
/**
* Hosts the sheets presented from inside it.
*
* A sheet's content normally lands at the app root, on the far side of the portal, where every
* context between the root and the call site is gone (floating-panels.md Gotcha 7). Inside a
* scope, a sheet renders into the scope's own host instead, so its content reads the scope's
* contexts — the navigator, the workspace providers — without bridging each one by hand. The
* scope only moves where the sheet draws: it still joins the app-wide sheet stack, so stacking,
* Back and the backdrop treat it like every other sheet.
*
* The host fills the scope, and the sheets presented in it size and dock to the scope, not the
* window. Sheets presented outside the scope keep drawing at the root, above it.
*/
export function SheetHostScope({
children,
style,
}: {
children: ReactNode;
style?: StyleProp<ViewStyle>;
}) {
const parent = useBottomSheetModalInternal();
const hostName = `sheet-host-${useId()}`;
const containerLayoutState = useSharedValue({ ...INITIAL_CONTAINER_LAYOUT });
const hostRef = useRef<View>(null);
// Mirrors Gorhom's own hosting container measurement, so a sheet in the scope lays out against
// the scope the way a root sheet lays out against the window.
const handleLayout = useCallback(
({ nativeEvent }: LayoutChangeEvent) => {
const { height } = nativeEvent.layout;
hostRef.current?.measure((_x, _y, _width, _height, _pageX, pageY) => {
const windowHeight = Dimensions.get("window").height;
const top = pageY ?? 0;
containerLayoutState.set({
height,
offset: {
top,
bottom: Math.max(0, windowHeight - (top + height + (StatusBar.currentHeight ?? 0))),
left: 0,
right: 0,
},
});
});
},
[containerLayoutState],
);
const value = useMemo(
() => ({ ...parent, hostName, containerLayoutState }),
[containerLayoutState, hostName, parent],
);
return (
<BottomSheetModalInternalProvider value={value}>
<View style={[styles.scope, style]}>
{children}
<View ref={hostRef} style={styles.host} pointerEvents="box-none" onLayout={handleLayout}>
<PortalHost name={hostName} />
</View>
</View>
</BottomSheetModalInternalProvider>
);
}
const styles = StyleSheet.create({
scope: {
flex: 1,
minHeight: 0,
},
host: {
...StyleSheet.absoluteFillObject,
},
});
@@ -40,6 +40,13 @@ type IsolatedBottomSheetModalProps = Omit<
BottomSheetModalProps,
"enableDismissOnClose" | "stackBehavior" | "children" | "backdropComponent"
> & {
/**
* Whether closing (drag, backdrop, Back) also dismisses, which unmounts the content. Default
* true: the owner hears `onDismiss` and drops its state. Pass `false` for a sheet whose close the
* owner may veto: it parks at index -1 with its content mounted, the owner hears `onChange(-1)`,
* and either snaps it back or unmounts it, which dismisses it from the stack.
*/
enableDismissOnClose?: boolean;
/**
* Nodes only. Gorhom also accepts a render function, but nothing here uses it and a bridge
* would have to reach around it.
@@ -84,19 +91,24 @@ export const IsolatedBottomSheetModal = forwardRef<
presentation = "push",
contextBridge,
backdropOpacity,
enableDismissOnClose = true,
...bottomSheetProps
} = props;
const renderBackdrop = useCallback(
(backdropProps: BottomSheetBackdropProps) => (
<SheetBackdrop {...backdropProps} opacity={backdropOpacity} />
<SheetBackdrop
{...backdropProps}
opacity={backdropOpacity}
pressBehavior={enableDismissOnClose ? "dismiss" : "close"}
/>
),
[backdropOpacity],
[backdropOpacity, enableDismissOnClose],
);
const modal = (
<GorhomBottomSheetModal
{...bottomSheetProps}
ref={modalRef}
enableDismissOnClose
enableDismissOnClose={enableDismissOnClose}
stackBehavior={presentation}
backdropComponent={backdropOpacity === undefined ? undefined : renderBackdrop}
>
@@ -154,3 +166,5 @@ export function useIsolatedBottomSheetVisibility({
}
export { SheetVisibleFrame } from "./visible-frame";
export { systemBackPress } from "./back-press";
export { SheetHostScope } from "./host-scope";
@@ -1,5 +1,15 @@
import { BottomSheetBackdrop, type BottomSheetBackdropProps } from "@gorhom/bottom-sheet";
export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) {
/**
* What a backdrop tap does. `dismiss` takes the top modal off the stack and unmounts it; `close`
* only slides this sheet down, so a sheet that keeps its content through a close can veto it.
*/
export type SheetBackdropPress = "dismiss" | "close";
export function SheetBackdrop({
pressBehavior: _pressBehavior,
...props
}: BottomSheetBackdropProps & { opacity?: number; pressBehavior: SheetBackdropPress }) {
// Gorhom's native backdrop closes its own sheet, which here is always the top one.
return <BottomSheetBackdrop {...props} appearsOnIndex={0} disappearsOnIndex={-1} />;
}
@@ -1,16 +1,30 @@
import {
BottomSheetBackdrop,
useBottomSheet,
useBottomSheetModal,
type BottomSheetBackdropProps,
} from "@gorhom/bottom-sheet";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, StyleSheet, View } from "react-native";
import type { SheetBackdropPress } from "./sheet-backdrop";
export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) {
export type { SheetBackdropPress };
export function SheetBackdrop({
pressBehavior,
...props
}: BottomSheetBackdropProps & { opacity?: number; pressBehavior: SheetBackdropPress }) {
const { dismiss } = useBottomSheetModal();
const { close } = useBottomSheet();
const { t } = useTranslation();
const dismissTopSheet = useCallback(() => dismiss(), [dismiss]);
const handlePress = useCallback(() => {
// A sheet that may veto its close slides itself down and lets its owner decide. The other
// sheets ask the provider for the top modal, so a lower backdrop caught mid-transition still
// dismisses the sheet the user sees.
if (pressBehavior === "close") close();
else dismiss();
}, [close, dismiss, pressBehavior]);
return (
<View style={StyleSheet.absoluteFill}>
@@ -28,7 +42,7 @@ export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: numb
accessibilityLabel={t("common.bottomSheetBackdrop")}
// The provider owns presentation order, including when a lower backdrop remounts.
// Keep this target until the portal unmounts: animation must not expose the sheet below.
onPress={dismissTopSheet}
onPress={handlePress}
style={StyleSheet.absoluteFill}
/>
</View>
@@ -126,8 +126,14 @@ export interface MenuSurfaceProps {
* popover (plus one flyout per open submenu) or a bottom sheet showing one page at a time.
*/
export function MenuSurface(props: MenuSurfaceProps): ReactElement | null {
const { presentation } = useMenuContext("MenuSurface");
const { presentation, open, setOpen } = useMenuContext("MenuSurface");
const active = useRetainedPanelActive();
// A menu belongs to the panel that opened it. When that panel leaves the screen the menu closes
// for good, rather than waiting hidden to reappear with the panel: a row that opened a sheet
// and promoted it to a tab, say, would otherwise bring the list back on the next tab switch.
useEffect(() => {
if (!active && open) setOpen(false);
}, [active, open, setOpen]);
// Portals escape the hidden panel. Remove the surface in its inactive commit,
// before freezing can suspend an async menu action's closing update.
if (!active) return null;
@@ -104,6 +104,13 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
});
if (selection) inputRef.current?.setSelection?.(selection.start, selection.end);
},
setSelection: (selection) => {
if (inputRef.current?.setSelection) {
inputRef.current.setSelection(selection.start, selection.end);
return;
}
inputRef.current?.setNativeProps?.({ selection });
},
reset: () => {
textRef.current = "";
const autoFocus = inputRef.current?.isFocused?.() ?? false;
@@ -77,6 +77,10 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
input.setSelectionRange(selection.start, selection.end);
}
},
setSelection: (selection) => {
const input = inputRef.current as WebTextInputElement | null;
input?.setSelectionRange?.(selection.start, selection.end);
},
reset: () => {
textRef.current = "";
const input = inputRef.current as WebTextInputElement | null;
@@ -8,6 +8,7 @@ export interface EditingTextInputHandle {
isFocused(): boolean;
getText(): string;
replaceText(text: string, selection?: { start: number; end: number }): void;
setSelection(selection: { start: number; end: number }): void;
/** Clear the editor and reset its intrinsic layout, preserving focus intent. */
reset(): void;
getNativeRef(): unknown;
+75 -19
View File
@@ -1,5 +1,6 @@
import { ComposerDockBackground } from "./internal/background";
export { ComposerDockBackground } from "./internal/background";
export { ComposerFullscreenBody, ComposerFullscreenFooter } from "./internal/fullscreen";
import { ScrollView } from "@/components/ui/scroll-view";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -8,22 +9,22 @@ import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings";
import { KeyboardTranslateView } from "@/keyboard/shift";
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
import { View, type LayoutChangeEvent, type ViewProps, StyleSheet } from "react-native";
import { StyleSheet as ThemedStyleSheet } from "react-native-unistyles";
import Animated, {
useAnimatedStyle,
useSharedValue,
useDerivedValue,
useAnimatedReaction,
type SharedValue,
} from "react-native-reanimated";
import { useKeyboardShift } from "@/keyboard/shift";
import {
resolveCenteredClearance,
resolveInlineComposerCapacity,
resolveComposerSlotCapacity,
updateComposerCapacity,
type ComposerCapacity,
} from "./internal/capacity";
const ViewportCapacity = createContext<SharedValue<number | undefined> | null>(null);
const ViewportSizing = createContext<SharedValue<ComposerCapacity | undefined> | null>(null);
interface ComposerViewportProps extends ViewProps {
bottomInset?: number;
@@ -55,7 +56,6 @@ function ComposerViewport({
},
[],
);
const capacity = useDerivedValue(() => sizing.value?.capacity);
const measureViewport = useCallback(
(event: LayoutChangeEvent) => {
measuredHeight.value = event.nativeEvent.layout.height;
@@ -66,20 +66,24 @@ function ComposerViewport({
return (
<View testID="composer-viewport" {...props} onLayout={measureViewport} collapsable={false}>
<ViewportCapacity.Provider value={capacity}>{children}</ViewportCapacity.Provider>
<ViewportSizing.Provider value={sizing}>{children}</ViewportSizing.Provider>
</View>
);
}
/** Bound the complete composer (including its controls), not just the text input. */
function ComposerViewportContent({ style, ...props }: ViewProps) {
function ComposerViewportContent({
style,
fullscreen = false,
...props
}: ViewProps & { fullscreen?: boolean }) {
const compact = useIsCompactFormFactor();
const capacity = useContext(ViewportCapacity);
if (!capacity) throw new Error("ComposerViewportContent requires ComposerViewport");
const sizing = useContext(ViewportSizing);
if (!sizing) throw new Error("ComposerViewportContent requires ComposerViewport");
const constraint = useAnimatedStyle(() => ({
// Only viewport changes and keyboard start/end events trigger layout. Motion
// stays in KeyboardTranslateView and never changes this constraint per frame.
maxHeight: resolveInlineComposerCapacity(capacity.value, compact),
maxHeight: resolveComposerSlotCapacity(sizing.value, { compact, fullscreen }),
}));
return (
@@ -108,37 +112,71 @@ function useCenteredClearance(safeAreaBottom: number) {
interface ComposerDockProps {
children: [ReactNode, ReactNode, ReactNode?];
centered?: boolean;
/**
* Fullscreen is a layout mode of the same mounted composer: the slot fills the
* stationary viewport over the content and lays out the keyboard itself, so the
* surface stops translating. The screen that owns the chrome hides it.
*/
fullscreen?: boolean;
}
/** The stationary viewport, translated surface, and bounded composer are one owner. */
export function ComposerDock({
children: [content, composer, overlay],
centered = false,
fullscreen = false,
}: ComposerDockProps) {
const insets = useSafeAreaInsets();
const contentMaxWidth = resolveContentMaxWidth(useAppSettings().settings);
const centeredClearance = useCenteredClearance(insets.bottom);
// Preserve the existing centered form's visual balance on tablets.
const bottomInset = centered ? HEADER_INNER_HEIGHT + 24 : 0;
const bottomInset = centered && !fullscreen ? HEADER_INNER_HEIGHT + 24 : 0;
const fullscreenSurface = [
themedStyles.fullscreenComposer,
{
paddingTop: insets.top,
paddingBottom: insets.bottom,
paddingLeft: insets.left,
paddingRight: insets.right,
},
];
if (centered) {
// Same tree in both modes. Fullscreen stops centering and translating, hides the
// setup fields, and lets the composer slot take the whole stationary viewport.
return (
<ComposerViewport
style={[dockStyles.centeredViewport, { paddingBottom: bottomInset }]}
style={
fullscreen
? dockStyles.viewport
: [dockStyles.centeredViewport, { paddingBottom: bottomInset }]
}
bottomInset={bottomInset}
centered
onLayout={centeredClearance.measureViewport}
>
<KeyboardTranslateView
style={[dockStyles.centered, { maxWidth: contentMaxWidth }]}
style={
fullscreen ? dockStyles.surface : [dockStyles.centered, { maxWidth: contentMaxWidth }]
}
enabled={!fullscreen}
bottomClearance={centeredClearance.value}
onLayout={centeredClearance.measureForm}
>
<ComposerViewportContent style={dockStyles.composer}>
<ScrollView style={dockStyles.setup} keyboardShouldPersistTaps="handled">
<ComposerViewportContent
style={fullscreen ? dockStyles.surface : dockStyles.composer}
fullscreen={fullscreen}
>
<ScrollView
style={fullscreen ? dockStyles.hiddenSetup : dockStyles.setup}
keyboardShouldPersistTaps="handled"
>
{content}
</ScrollView>
<ComposerViewportContent style={dockStyles.centeredComposer}>
{composer}
<ComposerViewportContent
style={fullscreen ? dockStyles.surface : dockStyles.centeredComposer}
fullscreen={fullscreen}
>
<View style={fullscreen ? fullscreenSurface : dockStyles.composer}>{composer}</View>
</ComposerViewportContent>
</ComposerViewportContent>
{overlay}
@@ -148,7 +186,7 @@ export function ComposerDock({
}
return (
<ComposerViewport style={dockStyles.viewport}>
<KeyboardTranslateView style={dockStyles.surface}>
<KeyboardTranslateView style={dockStyles.surface} enabled={!fullscreen}>
<View
testID="composer-dock-content"
collapsable={false}
@@ -162,8 +200,19 @@ export function ComposerDock({
{content}
</View>
</View>
<ComposerViewportContent style={dockStyles.composer}>
<View style={[dockStyles.composer, { paddingBottom: insets.bottom }]}>{composer}</View>
<ComposerViewportContent
style={fullscreen ? dockStyles.fullscreenSlot : dockStyles.composer}
fullscreen={fullscreen}
>
<View
style={
fullscreen
? fullscreenSurface
: [dockStyles.composer, { paddingBottom: insets.bottom }]
}
>
{composer}
</View>
</ComposerViewportContent>
{overlay}
</KeyboardTranslateView>
@@ -178,9 +227,16 @@ const dockStyles = StyleSheet.create({
surface: { flex: 1 },
content: { flex: 1, justifyContent: "flex-end" },
composer: { width: "100%", flexShrink: 1 },
fullscreenSlot: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0 },
centeredViewport: { flex: 1, alignItems: "center", justifyContent: "center" },
centered: { flexShrink: 1, width: "100%" },
// Reserve the composer's own capped height before the setup scroll view shrinks.
centeredComposer: { width: "100%", flexShrink: 0 },
setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 },
hiddenSetup: { display: "none" },
});
// The fullscreen composer paints its own surface, including under the safe areas.
const themedStyles = ThemedStyleSheet.create((theme) => ({
fullscreenComposer: { flex: 1, backgroundColor: theme.colors.surface1 },
}));
+35 -10
View File
@@ -1,48 +1,69 @@
export { ComposerDockBackground } from "./internal/background";
export { ComposerFullscreenBody, ComposerFullscreenFooter } from "./internal/fullscreen";
import { ScrollView } from "@/components/ui/scroll-view";
import { useState, useCallback, useMemo, type ReactNode } from "react";
import { useState, useCallback, useMemo, useRef, type ReactNode } from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { resolveInlineComposerCapacity } from "./internal/capacity";
import { useFullscreenSlotFrame } from "./internal/fullscreen";
import { useIsCompactFormFactor } from "@/constants/layout";
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
interface ComposerDockProps {
children: [ReactNode, ReactNode, ReactNode?];
centered?: boolean;
/** See the native dock: the same mounted composer fills the visible surface. */
fullscreen?: boolean;
}
export function ComposerDock({
children: [content, composer, overlay],
centered = false,
fullscreen = false,
}: ComposerDockProps) {
const compact = useIsCompactFormFactor();
const [availableHeight, setAvailableHeight] = useState<number>();
const [surfaceSize, setSurfaceSize] = useState<{ width: number; height: number }>();
const surfaceRef = useRef<View>(null);
const fullscreenFrame = useFullscreenSlotFrame(fullscreen, surfaceRef, surfaceSize);
const constraint = useMemo(
() => ({ maxHeight: resolveInlineComposerCapacity(availableHeight, compact) }),
[availableHeight, compact],
() => ({ maxHeight: resolveInlineComposerCapacity(surfaceSize?.height, compact) }),
[surfaceSize?.height, compact],
);
const measureViewport = useCallback((event: import("react-native").LayoutChangeEvent) => {
const { height } = event.nativeEvent.layout;
if (height > 0) setAvailableHeight(height);
const { width, height } = event.nativeEvent.layout;
if (height > 0) setSurfaceSize({ width, height });
}, []);
if (centered) {
return (
<View style={styles.centered} onLayout={measureViewport}>
<View ref={surfaceRef} style={styles.centered} onLayout={measureViewport}>
<View style={styles.form}>
<ScrollView style={styles.setup} keyboardShouldPersistTaps="handled">
{content}
</ScrollView>
<View style={[styles.centeredComposer, constraint]}>{composer}</View>
<View
style={
fullscreen
? [styles.fullscreenComposer, fullscreenFrame]
: [styles.centeredComposer, constraint]
}
>
{composer}
</View>
{overlay}
</View>
</View>
);
}
return (
<View style={styles.surface} onLayout={measureViewport}>
<View ref={surfaceRef} style={styles.surface} onLayout={measureViewport}>
<View style={styles.content}>{content}</View>
<View style={[styles.composer, constraint]}>{composer}</View>
<View
style={
fullscreen ? [styles.fullscreenComposer, fullscreenFrame] : [styles.composer, constraint]
}
>
{composer}
</View>
{overlay}
</View>
);
@@ -52,6 +73,10 @@ const styles = StyleSheet.create((theme) => ({
surface: { flex: 1, overflow: "hidden" },
content: { flex: 1, justifyContent: "flex-end" },
composer: { width: "100%", flexShrink: 1 },
// Fixed, not absolute: a touch pan that starts inside an overflow-hidden ancestor
// never reaches the zoomed visual viewport, while a fixed box chains to the viewport
// directly. React Native Web renders `fixed`; React Native's style type omits it.
fullscreenComposer: { position: "fixed" as "absolute" },
centered: {
flex: 1,
alignItems: "center",
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
resolveCenteredClearance,
resolveComposerSlotCapacity,
resolveInlineComposerCapacity,
resolveComposerCapacity,
updateComposerCapacity,
@@ -19,6 +20,20 @@ describe("composer viewport", () => {
expect(resolveInlineComposerCapacity(open.capacity, false)).toBe(245);
expect(resolveInlineComposerCapacity(undefined, true)).toBeUndefined();
});
it("lets a fullscreen composer fill the viewport while inline keeps its cap", () => {
const open = updateComposerCapacity(undefined, {
height: 582,
bottomInset: 24,
safeAreaBottom: 0,
centered: false,
keyboardShift: 308,
});
expect(resolveComposerSlotCapacity(open, { compact: true, fullscreen: true })).toBe(582);
expect(resolveComposerSlotCapacity(open, { compact: true, fullscreen: false })).toBe(147);
expect(resolveComposerSlotCapacity(undefined, { compact: true, fullscreen: true })).toBe(
undefined,
);
});
it("preserves the editing capacity when the keyboard closes", () => {
const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false };
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 });
@@ -10,6 +10,8 @@ interface ComposerGeometry {
export interface ComposerCapacity {
keyboardReserve: number;
capacity: number;
/** The stationary viewport; a fullscreen composer fills it and lays out the keyboard itself. */
viewportHeight: number;
}
export function resolveComposerCapacity(input: ComposerGeometry): number {
@@ -48,6 +50,7 @@ export function updateComposerCapacity(
return {
keyboardReserve,
capacity: resolveComposerCapacity({ ...input, keyboardShift: keyboardReserve }),
viewportHeight: input.height,
};
}
@@ -60,3 +63,19 @@ export function resolveInlineComposerCapacity(
if (capacity === undefined) return undefined;
return compact ? capacity * 0.6 : capacity;
}
export interface ComposerSlotLayout {
compact: boolean;
fullscreen: boolean;
}
/** The bound on the composer slot: inline capacity, or the whole viewport in fullscreen. */
export function resolveComposerSlotCapacity(
sizing: ComposerCapacity | undefined,
layout: ComposerSlotLayout,
): number | undefined {
"worklet";
if (sizing === undefined) return undefined;
if (layout.fullscreen) return sizing.viewportHeight;
return resolveInlineComposerCapacity(sizing.capacity, layout.compact);
}
@@ -0,0 +1,39 @@
import type { ReactNode } from "react";
import { StyleSheet, type ViewProps } from "react-native";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { KeyboardTranslateView, useKeyboardShift } from "@/keyboard/shift";
/**
* The editing region of a composer. Inline it only continues the shrink chain. In
* fullscreen it fills the stationary frame and reserves the keyboard's space at
* motion boundaries, the same boundary-time layout the dock capacity uses: the
* keyboard start/end events write `layoutShift`; frames in between move the footer.
*/
export function ComposerFullscreenBody({
fullscreen,
children,
}: {
fullscreen: boolean;
children: ReactNode;
}) {
const { layoutShift } = useKeyboardShift();
const reservation = useAnimatedStyle(() => ({
marginBottom: fullscreen ? layoutShift.value : 0,
}));
return (
<Animated.View style={[fullscreen ? styles.fullscreen : styles.inline, reservation]}>
{children}
</Animated.View>
);
}
/** The Send row of a fullscreen composer rides the keyboard like the inline dock surface. */
export function ComposerFullscreenFooter({ children, ...props }: ViewProps) {
return <KeyboardTranslateView {...props}>{children}</KeyboardTranslateView>;
}
// Plain React Native styles: these land on Reanimated views (docs/unistyles.md).
const styles = StyleSheet.create({
inline: { flexShrink: 1 },
fullscreen: { flex: 1, minHeight: 0 },
});
@@ -0,0 +1,89 @@
import { useLayoutEffect, useState, type ReactNode, type RefObject } from "react";
import { StyleSheet, View, type ViewProps } from "react-native";
/** The editing region of a composer; see the native file for the keyboard contract. */
export function ComposerFullscreenBody({
fullscreen,
children,
}: {
fullscreen: boolean;
children: ReactNode;
}) {
return <View style={fullscreen ? styles.fullscreen : styles.inline}>{children}</View>;
}
export function ComposerFullscreenFooter({ children, ...props }: ViewProps) {
return <View {...props}>{children}</View>;
}
export interface ComposerSlotFrame {
top: number;
left: number;
width: number;
height: number;
}
/**
* Where a fullscreen composer sits, in layout-viewport coordinates for a fixed box: the
* part of the dock surface the browser's visible viewport shows. A mobile browser
* keyboard shrinks or pans that viewport without resizing the page, so the frame
* follows it instead of the layout.
*/
export function useFullscreenSlotFrame(
active: boolean,
surfaceRef: RefObject<View | null>,
surfaceSize: { width: number; height: number } | undefined,
): ComposerSlotFrame | undefined {
const [frame, setFrame] = useState<ComposerSlotFrame>();
useLayoutEffect(() => {
if (!active) {
setFrame(undefined);
return;
}
const update = () => {
const surface = surfaceRef.current as unknown as HTMLElement | null;
if (!surface) return;
setFrame(resolveVisibleSurfaceFrame(surface.getBoundingClientRect(), readVisibleViewport()));
};
const visualViewport = window.visualViewport;
update();
visualViewport?.addEventListener("resize", update);
visualViewport?.addEventListener("scroll", update);
window.addEventListener("resize", update);
return () => {
visualViewport?.removeEventListener("resize", update);
visualViewport?.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
};
}, [active, surfaceRef, surfaceSize]);
return frame;
}
function readVisibleViewport() {
const viewport = window.visualViewport;
return {
top: viewport?.offsetTop ?? 0,
left: viewport?.offsetLeft ?? 0,
width: viewport?.width ?? window.innerWidth,
height: viewport?.height ?? window.innerHeight,
};
}
function resolveVisibleSurfaceFrame(
surface: { top: number; left: number; right: number; bottom: number },
viewport: { top: number; left: number; width: number; height: number },
): ComposerSlotFrame {
const top = Math.max(viewport.top, surface.top);
const left = Math.max(viewport.left, surface.left);
return {
top,
left,
width: Math.max(0, Math.min(viewport.left + viewport.width, surface.right) - left),
height: Math.max(0, Math.min(viewport.top + viewport.height, surface.bottom) - top),
};
}
const styles = StyleSheet.create({
inline: { flexShrink: 1 },
fullscreen: { flex: 1, minHeight: 0 },
});
@@ -210,6 +210,8 @@ describe("useAgentInputDraft live contract", () => {
});
const hydratedTextReplacement = getLatest().textReplacement;
// The editor applies a retained caret to the hydrated text, so hydration announces itself.
expect(hydratedTextReplacement.kind).toBe("hydrated");
await act(async () => {
getLatest().editText("hello world");
@@ -223,6 +225,7 @@ describe("useAgentInputDraft live contract", () => {
});
expect(getLatest().textReplacement).not.toBe(hydratedTextReplacement);
expect(getLatest().textReplacement.kind).toBe("replace");
expect(getLatest().textReplacement.text).toBe("replacement text");
await act(async () => {
@@ -123,10 +123,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
}));
const publishTextReplacement = useCallback(
(nextText: string) => {
(kind: "hydrated" | "replace", nextText: string) => {
textReplacementRevisionRef.current += 1;
setTextReplacement({
kind: "replace",
kind,
key: `${draftKey}:${textReplacementRevisionRef.current}`,
text: nextText,
});
@@ -176,7 +176,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
(nextText: string) => {
textPublication.cancel();
useDraftStore.getState().editDraftText({ draftKey, text: nextText });
publishTextReplacement(nextText);
publishTextReplacement("replace", nextText);
},
[draftKey, publishTextReplacement, textPublication],
);
@@ -229,7 +229,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
await useDraftStore.getState().hydrateDraftInput({ draftKey });
if (!cancelled) {
const hydratedText = useDraftStore.getState().getDraftInput(draftKey)?.text ?? "";
publishTextReplacement(hydratedText);
publishTextReplacement("hydrated", hydratedText);
setHydratedDraftKey(draftKey);
}
})();
@@ -6,6 +6,7 @@ import { ComposerDock } from "@/composer/dock";
import { useContainerWidthBelow } from "@/hooks/use-container-width";
import invariant from "tiny-invariant";
import { Composer } from "@/composer";
import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { ComposerImportPill } from "@/composer/draft/import-pill";
import { COMPOSER_PILL_CLEARANCE } from "@/composer/pill-styles";
@@ -385,6 +386,8 @@ export function WorkspaceDraftAgentTab({
}, [pendingCreateAttempt]);
const allowsEmptyAutoSubmit = pendingAutoSubmit?.allowEmptyText === true;
const isCompactFormFactor = useIsCompactFormFactor();
const editingSession = useComposerEditingSession("chat");
const isComposerFullscreen = useComposerFullscreen(editingSession);
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
COMPACT_FORM_FACTOR_WIDTH,
{ initialIsBelow: isCompactFormFactor },
@@ -628,10 +631,10 @@ export function WorkspaceDraftAgentTab({
return (
<FileDropZone style={styles.container}>
<ComposerDock>
<ComposerDock fullscreen={isComposerFullscreen}>
{dockContent}
<View style={animatedStaticStyles.inputAreaWrapper} onLayout={onInputAreaLayout}>
{importPillPress ? (
{importPillPress && !isComposerFullscreen ? (
<View style={styles.importPillRow}>
<View style={styles.importPillContent}>
<ComposerImportPill onPress={importPillPress} />
@@ -655,6 +658,7 @@ export function WorkspaceDraftAgentTab({
onChangeAttachments={draftInput.setAttachments}
cwd={composerState.workingDir}
clearDraft={draftInput.clear}
editingSession={editingSession}
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
onFocusInput={handleFocusInputCallback}
@@ -672,6 +676,8 @@ const animatedStaticStyles = RNStyleSheet.create({
inputAreaWrapper: {
width: "100%",
flexShrink: 1,
// Content-sized inline; fills the dock's fullscreen slot.
flexGrow: 1,
},
});
@@ -1,52 +1,61 @@
import { describe, expect, it } from "vitest";
import { ComposerEditingSession } from "./index";
import { ComposerEditingSession, ComposerEditingSessions } from "./index";
function leaveLiveDraft(session: ComposerEditingSession) {
session.captureForRemount({
snapshot: { text: "Unpublished live draft", selection: { start: 4, end: 17 } },
focused: true,
replacementKey: "draft:applied",
});
}
describe("composer editing remount handoff", () => {
it("keeps live text and selection when remounting from an initial draft publication", () => {
describe("composer editing session", () => {
it("publishes fullscreen changes once per transition", () => {
const session = new ComposerEditingSession();
leaveLiveDraft(session);
const handoff = session.takeHandoff({ kind: "initial", key: "draft:initial", text: "stale" });
expect(handoff?.snapshot).toEqual({
text: "Unpublished live draft",
selection: { start: 4, end: 17 },
});
expect(handoff?.focused).toBe(true);
const changes: boolean[] = [];
session.subscribe(() => changes.push(session.getFullscreen()));
session.setFullscreen(true);
session.setFullscreen(true);
session.setFullscreen(false);
expect(changes).toEqual([true, false]);
});
it("keeps local edits when the last applied replacement is still current", () => {
it("hands the retained caret and focus intent to exactly one later mount", () => {
const session = new ComposerEditingSession();
leaveLiveDraft(session);
const handoff = session.takeHandoff({
kind: "replace",
key: "draft:applied",
text: "old replacement",
});
expect(handoff?.snapshot).toEqual({
text: "Unpublished live draft",
selection: { start: 4, end: 17 },
});
expect(session.takeEditor()).toBeNull();
session.retainEditor({ selection: { start: 4, end: 17 }, focused: true });
expect(session.takeEditor()).toEqual({ selection: { start: 4, end: 17 }, focused: true });
expect(session.takeEditor()).toBeNull();
});
});
describe("composer editing sessions", () => {
const workspace = "server:workspace";
const target = JSON.stringify({ kind: "agent", agentId: "a" });
it("keeps one session per tab and purpose until the tab target changes", () => {
const sessions = new ComposerEditingSessions();
const chat = sessions.get(workspace, "tab-1", target, "chat");
expect(sessions.get(workspace, "tab-1", target, "chat")).toBe(chat);
expect(sessions.get(workspace, "tab-1", target, "terminal")).not.toBe(chat);
const retargeted = JSON.stringify({ kind: "agent", agentId: "b" });
expect(sessions.get(workspace, "tab-1", retargeted, "chat")).not.toBe(chat);
});
it("reports a tab as fullscreen while any of its composers is fullscreen", () => {
const sessions = new ComposerEditingSessions();
const notifications: boolean[] = [];
sessions.subscribe(() => notifications.push(sessions.isTabFullscreen(workspace, "tab-1")));
const chat = sessions.get(workspace, "tab-1", target, "chat");
sessions.get(workspace, "tab-2", target, "chat").setFullscreen(true);
expect(sessions.isTabFullscreen(workspace, "tab-1")).toBe(false);
expect(sessions.isTabFullscreen(workspace, null)).toBe(false);
chat.setFullscreen(true);
expect(sessions.isTabFullscreen(workspace, "tab-1")).toBe(true);
chat.setFullscreen(false);
expect(notifications).toEqual([false, true, false]);
});
it("drops sessions for closed tabs and keeps retained ones", () => {
const sessions = new ComposerEditingSessions();
const kept = sessions.get(workspace, "tab-1", target, "chat");
kept.setFullscreen(true);
sessions.get(workspace, "tab-2", target, "chat").setFullscreen(true);
sessions.retain(workspace, ["tab-1"]);
expect(sessions.get(workspace, "tab-1", target, "chat")).toBe(kept);
expect(sessions.isTabFullscreen(workspace, "tab-2")).toBe(false);
expect(sessions.get(workspace, "tab-2", target, "chat").getFullscreen()).toBe(false);
});
it.each(["New explicit draft", ""])(
"honors an explicit replacement made while the editor is absent: %j",
(text) => {
const session = new ComposerEditingSession();
leaveLiveDraft(session);
const handoff = session.takeHandoff({ kind: "replace", key: "draft:new", text });
expect(handoff?.snapshot).toEqual({
text,
selection: { start: text.length, end: text.length },
});
expect(handoff?.focused).toBe(true);
expect(session.takeHandoff({ kind: "replace", key: "draft:new", text })).toBeNull();
},
);
});
@@ -1,5 +1,6 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
@@ -7,62 +8,72 @@ import React, {
useSyncExternalStore,
type ReactNode,
} from "react";
import type { TextReplacement } from "@/composer/types";
import type { ComposerInputMode } from "@/composer/input-mode";
export interface ComposerEditingHandoff {
snapshot: { text: string; selection: { start: number; end: number } };
focused: boolean;
replacementKey: string;
export interface ComposerSelection {
start: number;
end: number;
}
/** Editing metadata outlives a layout's input; draft text still belongs to its draft owner. */
/** What an unmounting editor leaves for the next mount of the same session. */
export interface ComposerRetainedEditor {
selection: ComposerSelection;
/** The editor was focused, or meant to regain focus once its pane became active. */
focused: boolean;
}
/**
* Editing state that outlives the editor's mount. Draft text belongs to its draft
* owner; the editor keeps its own live buffer across layout modes. Switching between
* the compact and desktop screens remounts the editor, so the session carries the
* caret and focus intent across that remount while the draft owner carries the text.
*/
export class ComposerEditingSession {
private fullscreen = false;
private handoff: ComposerEditingHandoff | null = null;
private retained: ComposerRetainedEditor | null = null;
private listeners = new Set<() => void>();
constructor(private readonly onChange?: () => void) {}
getFullscreen = () => this.fullscreen;
/** Called by the unmounting editor; the next mount of this session takes it. */
retainEditor(retained: ComposerRetainedEditor) {
this.retained = retained;
}
takeEditor(): ComposerRetainedEditor | null {
const retained = this.retained;
this.retained = null;
return retained;
}
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
changePresentation(fullscreen: boolean, handoff: ComposerEditingHandoff) {
this.captureForRemount(handoff);
setFullscreen(fullscreen: boolean) {
if (fullscreen === this.fullscreen) return;
this.fullscreen = fullscreen;
for (const listener of this.listeners) listener();
}
captureForRemount(handoff: ComposerEditingHandoff) {
this.handoff = handoff;
}
peekHandoff(replacement: TextReplacement): ComposerEditingHandoff | null {
const handoff = this.handoff;
if (!handoff || replacement.kind === "initial" || replacement.key === handoff.replacementKey)
return handoff;
// A replacement command issued since capture supersedes the old editor's live buffer.
return {
snapshot: {
text: replacement.text,
selection: { start: replacement.text.length, end: replacement.text.length },
},
focused: handoff.focused,
replacementKey: replacement.key,
};
}
takeHandoff(replacement: TextReplacement) {
const handoff = this.peekHandoff(replacement);
this.handoff = null;
return handoff;
this.onChange?.();
}
}
class ComposerEditingSessions {
private workspaces = new Map<
string,
Map<string, { targetKey: string; sessions: Map<ComposerInputMode, ComposerEditingSession> }>
>();
interface TabSessions {
targetKey: string;
sessions: Map<ComposerInputMode, ComposerEditingSession>;
}
export class ComposerEditingSessions {
private workspaces = new Map<string, Map<string, TabSessions>>();
private listeners = new Set<() => void>();
private notify = () => {
for (const listener of this.listeners) listener();
};
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
get(workspaceKey: string, tabId: string, targetKey: string, purpose: ComposerInputMode) {
let workspace = this.workspaces.get(workspaceKey);
if (!workspace) {
@@ -76,17 +87,31 @@ class ComposerEditingSessions {
}
let session = tab.sessions.get(purpose);
if (!session) {
session = new ComposerEditingSession();
session = new ComposerEditingSession(this.notify);
tab.sessions.set(purpose, session);
}
return session;
}
/** A tab is fullscreen when any of its composers is; the screen hides its chrome for it. */
isTabFullscreen(workspaceKey: string, tabId: string | null): boolean {
if (tabId === null) return false;
const tab = this.workspaces.get(workspaceKey)?.get(tabId);
if (!tab) return false;
for (const session of tab.sessions.values()) if (session.getFullscreen()) return true;
return false;
}
retain(workspaceKey: string, tabIds: readonly string[]) {
const workspace = this.workspaces.get(workspaceKey);
if (!workspace) return;
const retained = new Set(tabIds);
for (const tabId of workspace.keys()) if (!retained.has(tabId)) workspace.delete(tabId);
let changed = false;
for (const tabId of workspace.keys()) {
if (retained.has(tabId)) continue;
workspace.delete(tabId);
changed = true;
}
if (workspace.size === 0) this.workspaces.delete(workspaceKey);
if (changed) this.notify();
}
}
@@ -140,3 +165,16 @@ export function useComposerEditingSession(
export function useComposerFullscreen(session: ComposerEditingSession): boolean {
return useSyncExternalStore(session.subscribe, session.getFullscreen, session.getFullscreen);
}
/** Whether the composer of a workspace tab is fullscreen, for the chrome that tab sits under. */
export function useComposerTabFullscreen(workspaceKey: string, tabId: string | null): boolean {
const sessions = useContext(SessionsContext);
const subscribe = useCallback(
(listener: () => void) => sessions?.subscribe(listener) ?? (() => {}),
[sessions],
);
const read = useCallback(
() => sessions?.isTabFullscreen(workspaceKey, tabId) ?? false,
[sessions, workspaceKey, tabId],
);
return useSyncExternalStore(subscribe, read, read);
}
+68 -9
View File
@@ -1,4 +1,8 @@
import { useComposerEditingSession, useComposerFullscreen } from "./editing-session";
import {
useComposerEditingSession,
useComposerFullscreen,
type ComposerEditingSession,
} from "./editing-session";
import type { ComposerTextSource } from "./text-source";
import { createStore, type StoreApi } from "zustand/vanilla";
import { useStore } from "zustand";
@@ -27,6 +31,7 @@ import {
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { usePanelStore } from "@/stores/panel-store";
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
import { useShallow } from "zustand/shallow";
import {
@@ -967,6 +972,12 @@ interface ComposerProps {
submitLabel?: string;
/** Overrides the mode's default placeholder, for text only the caller can build. */
placeholder?: string;
/**
* The host that also lays out the dock and chrome shares this session so its
* fullscreen state and the composer's agree. Defaults to the tab's session for
* `inputMode`.
*/
editingSession?: ComposerEditingSession;
}
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
@@ -1182,8 +1193,19 @@ function ComposerVoiceModeButton({
}
export function Composer({ isPaneFocused, ...props }: ComposerProps) {
// A compact overlay panel (agent list, explorer) covers the pane without taking its
// focus. The composer gives up input from the moment the user asks for the panel,
// not when its motion settles, so the exit action and autocomplete never race it.
// Pane focus stays put, so the keyboard the panel dismisses is a blur in the
// user's own pane: closing the panel does not bring focus back.
const isCompact = useIsCompactFormFactor();
const overlayPanelRequested = usePanelStore((state) => state.mobilePanel.target !== "agent");
const coveredByOverlayPanel = isCompact && overlayPanelRequested;
return (
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
<ComposerKeyboardScopeProvider
isPaneFocused={isPaneFocused}
isActiveComposer={isPaneFocused && !coveredByOverlayPanel}
>
<RenderProfile id="ComposerContent">
<ComposerContent {...props} />
</RenderProfile>
@@ -1234,6 +1256,7 @@ function ComposerContentImpl({
inputWrapperStyle,
isCompactLayout: isCompactLayoutOverride,
inputMode = "chat",
editingSession: providedEditingSession,
readOnly = false,
submitLabel,
placeholder,
@@ -1339,7 +1362,7 @@ function ComposerContentImpl({
const nextPendingFileId = useRef(0);
const isUploadingFile = pendingFiles.length > 0;
const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0);
const editingSession = useComposerEditingSession(inputMode);
const editingSession = useComposerEditingSession(inputMode, providedEditingSession);
const isFullscreen = useComposerFullscreen(editingSession);
const [sendError, setSendError] = useState<string | null>(null);
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false);
@@ -2250,9 +2273,23 @@ function ComposerContentImpl({
[githubSearchItems, selectedAttachments, handleToggleGithubItem],
);
// Fullscreen fills the dock slot: the chain down to the input stretches and drops
// the inline padding, which the fullscreen presentation supplies itself.
const inputAreaContainerStyle = useMemo(
() => [styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked],
[isComposerLocked],
() => [
styles.inputAreaContainer,
isComposerLocked && styles.inputAreaLocked,
isFullscreen && styles.inputAreaFullscreen,
],
[isComposerLocked, isFullscreen],
);
const inputAreaContentStyle = useMemo(
() => [styles.inputAreaContent, isFullscreen && styles.fullscreenFill],
[isFullscreen],
);
const messageInputContainerStyle = useMemo(
() => [styles.messageInputContainer, isFullscreen && styles.fullscreenFill],
[isFullscreen],
);
const attachmentTray = useMemo(
@@ -2363,15 +2400,19 @@ function ComposerContentImpl({
focusMessageInputForKeyboardAction={focusMessageInputForKeyboardAction}
isMessageInputFocused={isMessageInputFocused}
/>
<View style={animatedStaticStyles.container}>
<View
style={
isFullscreen ? animatedStaticStyles.fullscreenContainer : animatedStaticStyles.container
}
>
<AttachmentLightbox source={lightboxSource} onClose={handleLightboxClose} />
{/* Input area */}
<View style={inputAreaContainerStyle}>
<View style={styles.inputAreaContent}>
{queueList}
<View style={inputAreaContentStyle}>
{isFullscreen ? null : queueList}
{!isFullscreen && sendErrorNode}
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
<View ref={messageInputContainerRef} style={messageInputContainerStyle}>
<ComposerAutocompleteBinding
text={textSource}
cursor={cursor}
@@ -2475,6 +2516,12 @@ const animatedStaticStyles = RNStyleSheet.create({
flexShrink: 1,
position: "relative",
},
fullscreenContainer: {
flexDirection: "column",
flex: 1,
minHeight: 0,
position: "relative",
},
});
const styles = StyleSheet.create((theme: Theme) => ({
@@ -2496,6 +2543,18 @@ const styles = StyleSheet.create((theme: Theme) => ({
inputAreaLocked: {
opacity: 0.6,
},
inputAreaFullscreen: {
flex: 1,
minHeight: 0,
alignItems: "stretch",
paddingHorizontal: 0,
paddingBottom: 0,
},
fullscreenFill: {
flex: 1,
minHeight: 0,
maxWidth: "100%",
},
inputAreaContent: {
flexShrink: 1,
width: "100%",
@@ -0,0 +1,18 @@
import { useEffect } from "react";
import { BackHandler } from "react-native";
/** The hardware back button leaves fullscreen instead of the screen. */
export function useComposerFullscreenExit(
active: boolean,
onExit: () => void,
): ((node: unknown) => void) | undefined {
useEffect(() => {
if (!active) return;
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
onExit();
return true;
});
return () => subscription.remove();
}, [active, onExit]);
return undefined;
}
@@ -0,0 +1,23 @@
import { useOverlayLayer, useWebOverlayRegistration } from "@/lib/overlay-root";
/**
* Escape leaves fullscreen before app shortcuts see it. The registration only claims
* keys; focus stays where the user left it because the editor never remounts.
*/
export function useComposerFullscreenExit(
active: boolean,
onExit: () => void,
): ((node: unknown) => void) | undefined {
const layer = useOverlayLayer("modal");
return useWebOverlayRegistration({
active,
layer,
manageFocus: false,
onKeyDown: (event) => {
if (event.key !== "Escape") return false;
event.preventDefault();
onExit();
return true;
},
});
}
@@ -1,57 +0,0 @@
import { useEffect, type ReactNode } from "react";
import { Portal } from "@gorhom/portal";
import { BackHandler, View, useWindowDimensions } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { DEFAULT_FLOATING_PANEL_PORTAL_HOST } from "@/components/ui/floating-panel-portal";
import { useKeyboardShift } from "@/keyboard/shift";
/** Same-window host keeps the IME attached while escaping the dock's clipping. */
export function ComposerFullscreenHost({
children,
onExit,
active,
}: {
children: ReactNode;
onExit: () => void;
active: boolean;
}) {
useEffect(() => {
if (!active) return;
const handler = BackHandler.addEventListener("hardwareBackPress", () => {
onExit();
return true;
});
return () => handler.remove();
}, [active, onExit]);
const insets = useSafeAreaInsets();
const { height } = useWindowDimensions();
const { layoutShift } = useKeyboardShift();
const frame = useAnimatedStyle(() => ({
height: Math.max(0, height - layoutShift.value),
paddingTop: insets.top,
paddingBottom: insets.bottom,
paddingLeft: insets.left,
paddingRight: insets.right,
}));
return (
<Portal hostName={DEFAULT_FLOATING_PANEL_PORTAL_HOST}>
<View style={[styles.background, !active && styles.hidden]}>
<Animated.View style={[{ width: "100%" }, frame]}>{children}</Animated.View>
</View>
</Portal>
);
}
const styles = StyleSheet.create((theme) => ({
hidden: { display: "none" },
background: {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
backgroundColor: theme.colors.surface1,
},
}));
@@ -1,77 +0,0 @@
import { useMemo, useLayoutEffect, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { View } from "react-native";
import {
getOverlayRoot,
useOverlayLayer,
OverlayLayerProvider,
useWebOverlayRegistration,
} from "@/lib/overlay-root";
export function ComposerFullscreenHost({
children,
onExit,
active,
}: {
children: ReactNode;
onExit: () => void;
active: boolean;
}) {
const layer = useOverlayLayer("modal");
const registration = useWebOverlayRegistration({
active,
layer,
onKeyDown: (event) => {
if (event.key !== "Escape") return false;
event.preventDefault();
onExit();
return true;
},
});
const [viewport, setViewport] = useState(readVisibleViewport);
useLayoutEffect(() => {
if (!active) return;
const update = () => setViewport(readVisibleViewport());
const visualViewport = window.visualViewport;
update();
visualViewport?.addEventListener("resize", update);
visualViewport?.addEventListener("scroll", update);
window.addEventListener("resize", update);
return () => {
visualViewport?.removeEventListener("resize", update);
visualViewport?.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
};
}, [active]);
const frameStyle = useMemo(
() => ({
display: active ? ("flex" as const) : ("none" as const),
position: "absolute" as const,
top: viewport.top,
left: viewport.left,
width: viewport.width,
zIndex: layer,
pointerEvents: "auto" as const,
height: viewport.height,
}),
[active, layer, viewport],
);
return createPortal(
<OverlayLayerProvider layer={layer}>
<View ref={registration} style={frameStyle}>
{children}
</View>
</OverlayLayerProvider>,
getOverlayRoot(),
);
}
function readVisibleViewport() {
const viewport = window.visualViewport;
return {
top: viewport?.offsetTop ?? 0,
left: viewport?.offsetLeft ?? 0,
width: viewport?.width ?? window.innerWidth,
height: viewport?.height ?? window.innerHeight,
};
}
+109 -122
View File
@@ -84,6 +84,7 @@ import {
computeCanStartDictation,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
resolveRetainedSelection,
runAlternateSendAction,
runDefaultSendAction,
runMessageInputKeyboardAction,
@@ -523,8 +524,8 @@ function MessageInputAutoFocus({
autoFocusKey: string | undefined;
textInputRef: React.MutableRefObject<ComposerTextInputHandle | null>;
}) {
const { isActiveComposer } = useComposerKeyboardScope();
useAutoFocusOnWebEffect(textInputRef, enabled && isActiveComposer, autoFocusKey);
const { isPaneFocused } = useComposerKeyboardScope();
useAutoFocusOnWebEffect(textInputRef, enabled && isPaneFocused, autoFocusKey);
return null;
}
@@ -676,13 +677,7 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
);
}
return (
<View
style={[
styles.textInputScrollWrapper,
props.fullscreen && styles.fullscreenTextSurface,
props.compact && !props.fullscreen && styles.compactTextSurface,
]}
>
<View style={[styles.textInputScrollWrapper, props.fullscreen && styles.fullscreenTextSurface]}>
{!isWeb && props.compact && !props.fullscreen ? (
<Text
accessible={false}
@@ -947,6 +942,7 @@ interface SendMessageContext {
onSubmit: (payload: MessagePayload) => void;
onMinimizeHeight: () => void;
preserveHeightOnSubmit: boolean;
exitFullscreen: () => void;
}
function sendMessageImpl(ctx: SendMessageContext): void {
@@ -959,6 +955,7 @@ function sendMessageImpl(ctx: SendMessageContext): void {
) {
return;
}
ctx.exitFullscreen();
ctx.onSubmit({
text: trimmed,
attachments: ctx.attachments,
@@ -979,12 +976,14 @@ interface QueueMessageContext {
onQueue: ((payload: MessagePayload) => void) | undefined;
replaceText: (text: string) => void;
onMinimizeHeight: () => void;
exitFullscreen: () => void;
}
function queueMessageImpl(ctx: QueueMessageContext): void {
if (!ctx.onQueue) return;
const trimmed = ctx.value.trim();
if (!trimmed && ctx.attachments.length === 0) return;
ctx.exitFullscreen();
ctx.onQueue({ text: trimmed, attachments: ctx.attachments, cwd: ctx.cwd });
ctx.replaceText("");
ctx.onMinimizeHeight();
@@ -1040,11 +1039,8 @@ function getComposerInputSnapshot(
current: ComposerTextInputHandle | null,
fallbackText: string,
fallbackSelection: ComposerInputSnapshot["selection"],
mountedTextArea?: TextAreaHandle | null,
): ComposerInputSnapshot {
// The inner DOM ref can detach before the outer editing handle. Keep the mounted
// element available until this owner's ref callback captures the remount handoff.
const textArea = mountedTextArea ?? getWebTextAreaImpl(current);
const textArea = getWebTextAreaImpl(current);
const text = textArea?.value ?? current?.getText() ?? fallbackText;
const start = textArea?.selectionStart ?? fallbackSelection.start;
const end = textArea?.selectionEnd ?? fallbackSelection.end;
@@ -1244,7 +1240,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
textReplacement,
submitLabel,
} = resolveMessageInputProps(props);
const { isActiveComposer } = useComposerKeyboardScope();
const { isPaneFocused } = useComposerKeyboardScope();
const mode = resolveComposerInputMode(inputMode);
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
@@ -1269,18 +1265,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const inputWrapperRef = useRef<View | null>(null);
const textInputRef = useRef<ComposerTextInputHandle | null>(null);
const isInputFocusedRef = useRef(false);
const handoff = editingSession.peekHandoff(textReplacement);
const focusIntentRef = useRef(Boolean(handoff?.focused));
const valueRef = useRef(handoff?.snapshot.text ?? value);
const selectionRef = useRef(
handoff?.snapshot.selection ?? { start: value.length, end: value.length },
);
const activeComposerRef = useRef(isActiveComposer);
activeComposerRef.current = isActiveComposer;
const focusIntentRef = useRef(false);
const valueRef = useRef(value);
const selectionRef = useRef({ start: value.length, end: value.length });
const paneFocusedRef = useRef(isPaneFocused);
paneFocusedRef.current = isPaneFocused;
const [webTextArea, setWebTextArea] = useState<HTMLElement | null>(null);
const appliedTextReplacementKeyRef = useRef(textReplacement.key);
const textReplacementRef = useRef(textReplacement);
textReplacementRef.current = textReplacement;
const webTextareaRef = useRef<HTMLElement | null>(null);
const updateLiveTextPresence = useCallback((text: string) => {
const nextHasLiveText = text.trim().length > 0;
@@ -1289,43 +1280,53 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
setHasLiveText(nextHasLiveText);
}, []);
const assignTextInput = useCallback(
(input: ComposerTextInputHandle | null) => {
const previous = textInputRef.current;
if (!input && previous) {
editingSession.captureForRemount({
snapshot: getComposerInputSnapshot(
previous,
valueRef.current,
selectionRef.current,
webTextareaRef.current,
),
focused: previous.isFocused() || isInputFocusedRef.current,
replacementKey: appliedTextReplacementKeyRef.current,
});
}
textInputRef.current = input;
if (isWeb) {
const element = getWebTextAreaImpl(input) as HTMLElement | null;
webTextareaRef.current = element;
setWebTextArea(element);
}
if (!input) return;
const transfer = editingSession.takeHandoff(textReplacementRef.current);
if (transfer) {
appliedTextReplacementKeyRef.current = textReplacementRef.current.key;
valueRef.current = transfer.snapshot.text;
updateLiveTextPresence(transfer.snapshot.text);
selectionRef.current = transfer.snapshot.selection;
input.replaceText(transfer.snapshot.text, transfer.snapshot.selection);
if (transfer.focused && activeComposerRef.current) input.focus();
}
},
[editingSession, updateLiveTextPresence],
);
// One editor stays mounted through every layout mode, so this ref only follows
// the editor's own mount and unmount.
const assignTextInput = useCallback((input: ComposerTextInputHandle | null) => {
textInputRef.current = input;
if (!isWeb) return;
const element = getWebTextAreaImpl(input) as HTMLElement | null;
webTextareaRef.current = element;
setWebTextArea(element);
}, []);
// Focus intent outlives a blur only while the pane is not the user's pane (a retained
// tab hidden, a sheet over it); the pane coming back restores focus. A blur while the
// pane is theirs drops it, whatever the layout mode: the user dismissed the keyboard,
// or a compact overlay panel did on their behalf, and nothing later refocuses.
useLayoutEffect(() => {
if (isActiveComposer && focusIntentRef.current) textInputRef.current?.focus();
}, [isActiveComposer]);
if (isPaneFocused && focusIntentRef.current) textInputRef.current?.focus();
}, [isPaneFocused]);
// The compact and desktop screens mount the editor in different trees, so a
// viewport that crosses the breakpoint remounts it. The draft owner brings the
// text back; the session brings the caret and focus intent back. On web the
// owner may still hold an unpublished edit while the new editor mounts, so the
// caret is placed now against the text on screen and again when the owner's
// hydrated text arrives.
const retainedSelectionRef = useRef<{ start: number; end: number } | null>(null);
useLayoutEffect(() => {
const retained = editingSession.takeEditor();
if (retained) {
const text = textInputRef.current?.getText() ?? valueRef.current;
const selection = resolveRetainedSelection(retained.selection, text.length);
retainedSelectionRef.current = retained.selection;
selectionRef.current = selection;
textInputRef.current?.setSelection(selection);
if (retained.focused) {
focusIntentRef.current = true;
if (paneFocusedRef.current) textInputRef.current?.focus();
}
}
return () => {
editingSession.retainEditor({
selection: getComposerInputSnapshot(
textInputRef.current,
valueRef.current,
selectionRef.current,
).selection,
focused: isInputFocusedRef.current || focusIntentRef.current,
});
};
}, [editingSession]);
const getLiveText = useCallback(() => valueRef.current, []);
const composerHeight = useComposerHeight({
getText: getLiveText,
@@ -1340,29 +1341,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const updateComposerHeightForText = measuredComposerHeight?.onTextChange;
const resetComposerHeight = measuredComposerHeight?.reset;
const changePresentation = useCallback(
(fullscreen: boolean) => {
if (editingSession.getFullscreen() === fullscreen) return;
const snapshot = getComposerInputSnapshot(
textInputRef.current,
valueRef.current,
selectionRef.current,
);
editingSession.changePresentation(fullscreen, {
snapshot,
focused: textInputRef.current?.isFocused() ?? isInputFocusedRef.current,
replacementKey: appliedTextReplacementKeyRef.current,
});
valueRef.current = snapshot.text;
selectionRef.current = snapshot.selection;
},
[editingSession],
);
const exitFullscreen = useCallback(() => changePresentation(false), [changePresentation]);
const exitFullscreen = useCallback(() => editingSession.setFullscreen(false), [editingSession]);
const enterFullscreen = useCallback(() => editingSession.setFullscreen(true), [editingSession]);
// The conversation sizes itself against the inline composer; fullscreen covers it.
const handleComposerLayout = useCallback(
(event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height),
[onHeightChange],
(event: LayoutChangeEvent) => {
if (!isFullscreen) onHeightChange?.(event.nativeEvent.layout.height);
},
[isFullscreen, onHeightChange],
);
const replaceText = useCallback(
@@ -1427,8 +1414,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
updateComposerHeightForText?.(valueRef.current, textReplacement.text);
valueRef.current = textReplacement.text;
updateLiveTextPresence(textReplacement.text);
const retainedSelection =
textReplacement.kind === "hydrated" ? retainedSelectionRef.current : null;
retainedSelectionRef.current = null;
if (textReplacement.text === "") {
textInputRef.current?.reset();
} else if (retainedSelection) {
const selection = resolveRetainedSelection(retainedSelection, textReplacement.text.length);
selectionRef.current = selection;
textInputRef.current?.replaceText(textReplacement.text, selection);
} else {
textInputRef.current?.replaceText(textReplacement.text);
}
@@ -1650,6 +1644,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onSubmit,
onMinimizeHeight: minimizeInputHeight,
preserveHeightOnSubmit,
exitFullscreen,
});
}, [
allowEmptySubmit,
@@ -1661,6 +1656,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
minimizeInputHeight,
preserveHeightOnSubmit,
updateLiveTextPresence,
exitFullscreen,
]);
const handleQueueMessage = useCallback(
@@ -1672,8 +1668,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onQueue,
replaceText,
onMinimizeHeight: minimizeInputHeight,
exitFullscreen,
}),
[attachments, cwd, onQueue, replaceText, minimizeInputHeight],
[attachments, cwd, onQueue, replaceText, minimizeInputHeight, exitFullscreen],
);
const handleDefaultSendAction = useCallback(() => {
@@ -1696,17 +1693,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
});
}, [defaultSendBehavior, isAgentRunning, handleSendMessage, handleQueueMessage, onQueue]);
const getWebTextArea = useCallback(
(): TextAreaHandle | null => getWebTextAreaImpl(textInputRef.current),
[],
);
useLayoutEffect(() => {
if (isWeb) {
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
}
}, [getWebTextArea, isFullscreen]);
usePasteImagesEffect({
textarea: webTextArea,
isConnected,
@@ -1832,11 +1818,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}, [onFocusChange]);
const handleInputBlur = useCallback(() => {
if (activeComposerRef.current && !isFullscreen) focusIntentRef.current = false;
if (paneFocusedRef.current) focusIntentRef.current = false;
isInputFocusedRef.current = false;
setIsInputFocused(false);
onFocusChange?.(false);
}, [isFullscreen, onFocusChange]);
}, [onFocusChange]);
const handlePasteError = useCallback(
(message: string) => {
@@ -1888,8 +1874,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
styles.textInput,
mode.isMonospace && styles.textInputMonospace,
isFullscreen ? styles.fullscreenTextInput : composerHeightStyle,
isCompact && !isFullscreen && styles.compactTextInput,
],
[composerHeightStyle, mode.isMonospace, isFullscreen],
[composerHeightStyle, mode.isMonospace, isFullscreen, isCompact],
);
// Static content has no textarea to mirror, so it grows with its own text
// instead of the measured input height.
@@ -1897,13 +1884,22 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
() => [styles.textInput, mode.isMonospace && styles.textInputMonospace, styles.readOnlyText],
[mode.isMonospace],
);
// Fullscreen always shows Send; without content it is disabled, and looks it.
const isPresentedSendDisabled = resolvePresentationSendDisabled({
fullscreen: isFullscreen,
disabled: isSendButtonDisabled,
hasText: hasLiveText,
attachments,
hasExternalContent,
allowEmpty: allowEmptySubmit,
});
const sendButtonCombinedStyle = useMemo(
() => [
styles.sendButton,
submitLabel ? styles.sendButtonLabeled : undefined,
isSendButtonDisabled && styles.buttonDisabled,
isPresentedSendDisabled && styles.buttonDisabled,
],
[isSendButtonDisabled, submitLabel],
[isPresentedSendDisabled, submitLabel],
);
const overlayContainerStyle = useMemo(
() => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }],
@@ -1942,14 +1938,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
canPressLoadingButton={canPressLoadingButton}
onSubmitLoadingPress={onSubmitLoadingPress}
onDefaultSendAction={handleDefaultSendAction}
isSendButtonDisabled={resolvePresentationSendDisabled({
fullscreen: isFullscreen,
disabled: isSendButtonDisabled,
hasText: hasLiveText,
attachments,
hasExternalContent,
allowEmpty: allowEmptySubmit,
})}
isSendButtonDisabled={isPresentedSendDisabled}
submitAccessibilityLabel={submitAccessibilityLabel}
sendButtonCombinedStyle={sendButtonCombinedStyle}
isSubmitLoading={isSubmitLoading}
@@ -1968,11 +1957,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
canPressLoadingButton,
onSubmitLoadingPress,
handleDefaultSendAction,
isSendButtonDisabled,
hasLiveText,
attachments,
hasExternalContent,
allowEmptySubmit,
isPresentedSendDisabled,
submitAccessibilityLabel,
sendButtonCombinedStyle,
isSubmitLoading,
@@ -2008,9 +1993,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onBlur={handleInputBlur}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
scrollEnabled={isComposerScrollEnabled}
autoFocus={
Boolean(editingSession.peekHandoff(textReplacement)?.focused) && isActiveComposer
}
autoFocus={false}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
onPasteImages={onPasteImages}
@@ -2029,8 +2012,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
[
readOnly,
textReplacement,
editingSession,
isActiveComposer,
assignTextInput,
textInputStyle,
readOnlyTextStyle,
@@ -2162,11 +2143,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
handleRealtimeVoiceStop,
],
);
const enterFullscreen = useCallback(() => changePresentation(true), [changePresentation]);
return (
<View
ref={rootRef}
style={styles.container}
style={isFullscreen ? styles.fullscreenContainer : styles.container}
testID="message-input-root"
onLayout={handleComposerLayout}
>
@@ -2177,8 +2157,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
/>
<ComposerInputPresentation
fullscreen={isFullscreen}
iconSize={buttonIconSize}
active={isActiveComposer}
sendIconSize={buttonIconSize}
surface={textSurface}
toolbar={toolbar}
send={sendButton}
@@ -2207,6 +2186,10 @@ const styles = StyleSheet.create((theme: Theme) => ({
flexShrink: 1,
position: "relative",
},
fullscreenContainer: {
flex: 1,
position: "relative",
},
inputWrapper: {
flexShrink: 1,
flexDirection: "column",
@@ -2241,14 +2224,18 @@ const styles = StyleSheet.create((theme: Theme) => ({
inputWrapperReadOnly: {
borderStyle: "dotted",
},
compactTextSurface: { paddingRight: 36 },
// The band the fullscreen glyph sits in is the input's own padding, so a tap
// anywhere in it (under the glyph, or where the glyph would be) is a tap on the
// editor: it focuses, shows the keyboard, and places the caret. Reserved whether
// or not the glyph shows, so text never reflows when it appears.
compactTextInput: { paddingRight: 36 },
fullscreenTextSurface: { flex: 1, minHeight: 0 },
fullscreenTextInput: { flex: 1, height: "100%", textAlignVertical: "top" },
lineMeasurement: {
position: "absolute",
top: 0,
left: 0,
right: 36,
right: 0,
opacity: 0,
maxHeight: undefined,
minHeight: undefined,
@@ -3,19 +3,31 @@ import { View, type ViewProps } from "react-native";
import { Maximize2, Minimize2 } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { ComposerFullscreenBody, ComposerFullscreenFooter } from "@/composer/dock";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { isWeb } from "@/constants/platform";
import { ICON_SIZE } from "@/styles/theme";
import { StyleSheet } from "react-native-unistyles";
import { ComposerFullscreenHost } from "./fullscreen-host";
import { useComposerFullscreenExit } from "./fullscreen-exit";
import { resolveGlyphRailOffset } from "./state";
const CONTROL_SIZE = 28;
const FULLSCREEN_GLYPH_SIZE = ICON_SIZE.md;
function keepEditorFocused(event: import("react-native").PointerEvent) {
if (isWeb) event.preventDefault();
}
/** Both presentations receive the same editing surface and send capability. */
/**
* One tree for both layout modes. The editing surface keeps its position so the
* mounted editor, its native buffer, selection, and focus survive every toggle;
* only the chrome around it changes. Fullscreen shows exit top right and Send
* bottom right, both on the rail the inline Send button uses; a send error sits
* in the same footer as Send.
*/
export function ComposerInputPresentation({
fullscreen,
iconSize,
active,
sendIconSize,
surface,
toolbar,
send,
@@ -30,8 +42,7 @@ export function ComposerInputPresentation({
overlay,
}: {
fullscreen: boolean;
iconSize: number;
active: boolean;
sendIconSize: number;
surface: ReactNode;
toolbar: ReactNode;
send: ReactNode;
@@ -46,55 +57,33 @@ export function ComposerInputPresentation({
overlay: ReactNode;
}) {
const { t } = useTranslation();
// ArrowUp ends at x19; the fullscreen glyphs end at x21 in Lucide's 24px viewBox.
// Compensate for that difference to align visible ink with Send, not just boxes.
const railAdjustment = iconSize / 12;
// A retained tab keeps its fullscreen editor mounted while hidden; the global exit
// action (Escape, hardware back) belongs only to the pane the user is in.
const { isActiveComposer } = useComposerKeyboardScope();
const exitScopeRef = useComposerFullscreenExit(fullscreen && isActiveComposer, onExit);
const railOffset = resolveGlyphRailOffset({
controlSize: CONTROL_SIZE,
sendIconSize,
glyphIconSize: FULLSCREEN_GLYPH_SIZE,
});
const maximizeIcon = useCallback(
(color: string) => <Maximize2 color={color} size={iconSize} />,
[iconSize],
(color: string) => <Maximize2 color={color} size={FULLSCREEN_GLYPH_SIZE} />,
[],
);
const minimizeIcon = useCallback(
(color: string) => <Minimize2 color={color} size={iconSize} />,
[iconSize],
(color: string) => <Minimize2 color={color} size={FULLSCREEN_GLYPH_SIZE} />,
[],
);
const enterStyle = useMemo(
() => [styles.fullscreenButton, { right: -6 + railAdjustment }],
[railAdjustment],
);
const exitStyle = useMemo(
() => [styles.iconButton, { marginRight: railAdjustment }],
[railAdjustment],
);
if (!fullscreen)
return (
<>
<View ref={inputWrapperRef} style={inputWrapperStyle} pointerEvents={inputPointerEvents}>
{attachmentSlot}
<View style={styles.inlineTextSurface}>
{surface}
{showFullscreenButton ? (
<Button
variant="ghost"
size="xs"
accessibilityLabel={t("composer.input.fullscreen")}
leftIcon={maximizeIcon}
onPointerDown={keepEditorFocused}
onPress={onEnter}
style={enterStyle}
hitSlop={8}
>
{null}
</Button>
) : null}
</View>
{toolbar}
</View>
{overlay}
</>
);
// The toolbar row bleeds 6px past the input padding; the inline glyph sits on that edge.
const enterStyle = useMemo(() => [styles.enterButton, { right: -6 + railOffset }], [railOffset]);
const exitStyle = useMemo(() => [styles.iconButton, { marginRight: railOffset }], [railOffset]);
return (
<ComposerFullscreenHost onExit={onExit} active={active}>
<View style={styles.fullscreen} testID="composer-fullscreen">
<View
ref={exitScopeRef}
style={fullscreen ? styles.fullscreen : styles.inline}
testID={fullscreen ? "composer-fullscreen" : undefined}
>
{fullscreen ? (
<View style={styles.exitRow}>
<Button
variant="ghost"
@@ -109,28 +98,60 @@ export function ComposerInputPresentation({
{null}
</Button>
</View>
{surface}
{error}
<View style={styles.sendRow}>{send}</View>
</View>
</ComposerFullscreenHost>
) : null}
<ComposerFullscreenBody fullscreen={fullscreen}>
<View
ref={inputWrapperRef}
style={fullscreen ? styles.fullscreenInputWrapper : inputWrapperStyle}
pointerEvents={inputPointerEvents}
>
{fullscreen ? null : attachmentSlot}
<View style={fullscreen ? styles.fullscreenTextSurface : styles.inlineTextSurface}>
{surface}
{showFullscreenButton && !fullscreen ? (
<Button
variant="ghost"
size="xs"
accessibilityLabel={t("composer.input.fullscreen")}
leftIcon={maximizeIcon}
onPointerDown={keepEditorFocused}
onPress={onEnter}
style={enterStyle}
hitSlop={8}
>
{null}
</Button>
) : null}
</View>
{fullscreen ? null : toolbar}
</View>
</ComposerFullscreenBody>
{fullscreen ? (
<ComposerFullscreenFooter style={styles.footer}>
{error}
<View style={styles.sendRow}>{send}</View>
</ComposerFullscreenFooter>
) : null}
{overlay}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
inline: { flexShrink: 1, position: "relative" },
inlineTextSurface: { flexShrink: 1, position: "relative" },
iconButton: {
width: 28,
height: 28,
width: CONTROL_SIZE,
height: CONTROL_SIZE,
padding: 0,
borderWidth: 0,
borderRadius: theme.borderRadius.full,
},
fullscreenButton: {
enterButton: {
position: "absolute",
top: 0,
width: 28,
height: 28,
width: CONTROL_SIZE,
height: CONTROL_SIZE,
padding: 0,
borderWidth: 0,
borderRadius: theme.borderRadius.full,
@@ -141,6 +162,10 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[4],
gap: theme.spacing[3],
},
fullscreenInputWrapper: { flex: 1, minHeight: 0 },
fullscreenTextSurface: { flex: 1, minHeight: 0 },
exitRow: { alignItems: "flex-end", flexShrink: 0 },
sendRow: { flexShrink: 0, alignItems: "flex-end" },
// The send error rides the keyboard with Send; below the body it would sit behind it.
footer: { flexShrink: 0, gap: theme.spacing[3] },
sendRow: { alignItems: "flex-end" },
}));
+25 -3
View File
@@ -5,6 +5,8 @@ import {
resolveActiveSendBehavior,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
resolveGlyphRailOffset,
resolveRetainedSelection,
runAlternateSendAction,
runDefaultSendAction,
runMessageInputKeyboardAction,
@@ -333,10 +335,30 @@ describe("composer surface text", () => {
);
});
it("uses the live handoff when its replacement has already been applied", () => {
it("keeps the live buffer when the initial publication is already applied", () => {
const replacement = { kind: "initial" as const, key: "draft:0", text: "Stale publication" };
expect(resolveComposerSurfaceText("Live remount handoff", replacement, "draft:0")).toBe(
"Live remount handoff",
expect(resolveComposerSurfaceText("Live edited draft", replacement, "draft:0")).toBe(
"Live edited draft",
);
});
});
describe("retained selection", () => {
it("keeps a caret that fits and clamps one that no longer does", () => {
expect(resolveRetainedSelection({ start: 4, end: 17 }, 40)).toEqual({ start: 4, end: 17 });
expect(resolveRetainedSelection({ start: 4, end: 17 }, 10)).toEqual({ start: 4, end: 10 });
expect(resolveRetainedSelection({ start: 12, end: 17 }, 10)).toEqual({ start: 10, end: 10 });
expect(resolveRetainedSelection({ start: -1, end: 3 }, 10)).toEqual({ start: 0, end: 3 });
});
});
describe("fullscreen glyph rail", () => {
it("nudges 16px fullscreen glyphs onto the Send arrow's ink edge", () => {
expect(
resolveGlyphRailOffset({ controlSize: 28, sendIconSize: 16, glyphIconSize: 16 }),
).toBeCloseTo(4 / 3);
expect(
resolveGlyphRailOffset({ controlSize: 28, sendIconSize: 20, glyphIconSize: 16 }),
).toBeCloseTo(1 / 6);
});
});
+28
View File
@@ -193,6 +193,24 @@ export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise<
await ctx.voice.stopVoice();
}
/**
* Offset that aligns a glyph's visible ink with the Send arrow's ink when both sit in
* equal controls on the same right rail. In Lucide's 24px viewBox ArrowUp ends at
* x19 while Maximize2/Minimize2 end at x21, so equal boxes leave the fullscreen
* glyphs visibly past the arrow.
*/
export function resolveGlyphRailOffset(input: {
controlSize: number;
sendIconSize: number;
glyphIconSize: number;
}): number {
const arrowInkInset =
(input.controlSize - input.sendIconSize) / 2 + (5 * input.sendIconSize) / 24;
const glyphInkInset =
(input.controlSize - input.glyphIconSize) / 2 + (3 * input.glyphIconSize) / 24;
return arrowInkInset - glyphInkInset;
}
export function resolveComposerSurfaceText(
liveText: string,
replacement: TextReplacement,
@@ -200,3 +218,13 @@ export function resolveComposerSurfaceText(
): string {
return replacement.key === appliedReplacementKey ? liveText : replacement.text;
}
/** A retained caret must fit the text the remounted editor shows. */
export function resolveRetainedSelection(
retained: { start: number; end: number },
textLength: number,
): { start: number; end: number } {
const end = Math.min(Math.max(retained.end, 0), textLength);
const start = Math.min(Math.max(retained.start, 0), end);
return { start, end };
}
+18 -1
View File
@@ -7,18 +7,35 @@ import {
} from "react";
interface ComposerKeyboardScopeValue {
/**
* The pane this composer sits in is the user's pane. Focus restoration follows it:
* the editor keeps its focus intent across blurs that happen while the pane is not
* the user's (a retained tab hidden, a sheet over it) and brings focus back when
* the pane is theirs again.
*/
isPaneFocused: boolean;
/**
* The composer takes input right now: keyboard actions, shortcuts, autocomplete,
* and the fullscreen exit action. False while a compact overlay panel covers the
* pane, which does not take pane focus.
*/
isActiveComposer: boolean;
}
const ComposerKeyboardScopeContext = createContext<ComposerKeyboardScopeValue>({
isPaneFocused: false,
isActiveComposer: false,
});
export function ComposerKeyboardScopeProvider({
isPaneFocused,
isActiveComposer,
children,
}: PropsWithChildren<ComposerKeyboardScopeValue>): ReactElement {
const value = useMemo(() => ({ isActiveComposer }), [isActiveComposer]);
const value = useMemo(
() => ({ isPaneFocused, isActiveComposer }),
[isPaneFocused, isActiveComposer],
);
return (
<ComposerKeyboardScopeContext.Provider value={value}>
{children}
@@ -1,7 +1,8 @@
import React, { act, type ReactNode } from "react";
import React, { act, useCallback, useState, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { Pressable, Text } from "react-native";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RetainedPanelActivity } from "@/components/retained-panel";
import { ComposerTrackPill, ComposerTrackRow, type ComposerTrackPillSegment } from "./tracks";
const SUBAGENT_SEGMENTS: ComposerTrackPillSegment[] = [{ bucket: null, text: "3 subagents" }];
@@ -109,6 +110,43 @@ describe("composer track panel", () => {
expect(row("row")).not.toBeNull();
});
it("closes for good when the panel it belongs to leaves the screen", () => {
const onPress = vi.fn();
function Harness() {
const [active, setActive] = useState(true);
const hide = useCallback(() => setActive(false), []);
const show = useCallback(() => setActive(true), []);
return (
<>
<Pressable accessibilityRole="button" testID="hide" onPress={hide} />
<Pressable accessibilityRole="button" testID="show" onPress={show} />
<RetainedPanelActivity active={active}>
<ComposerTrackPill testID="pill" segments={SUBAGENT_SEGMENTS} panelTitle="Subagents">
<ComposerTrackRow
accessibilityLabel="Subagent one"
testID="row"
closeOnSelect={false}
onPress={onPress}
>
<Text>Subagent one</Text>
</ComposerTrackRow>
</ComposerTrackPill>
</RetainedPanelActivity>
</>
);
}
mount(<Harness />);
openPanel();
expect(isPanelOpen()).toBe(true);
click(row("hide") as Element);
click(row("show") as Element);
expect(isPanelOpen()).toBe(false);
expect(row("row")).toBeNull();
});
it("leaves the panel alone when a row's own action button is pressed", () => {
const onPress = vi.fn();
const onAction = vi.fn();
+5 -1
View File
@@ -9,8 +9,12 @@ export interface MessagePayload {
forceSend?: boolean;
}
/**
* Text the draft owner hands the editor. `hydrated` is the owner's answer to a mount:
* the draft's text as persisted or still pending from the editor that unmounted.
*/
export interface TextReplacement {
kind: "initial" | "replace";
kind: "initial" | "hydrated" | "replace";
key: string;
text: string;
}
+15 -5
View File
@@ -1,5 +1,5 @@
import { useCallback, useMemo } from "react";
import { useHosts } from "@/runtime/host-runtime";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { useDownloadStore } from "@/stores/download-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
@@ -12,8 +12,9 @@ interface UseFileDownloadParams {
/**
* Returns a stable callback that downloads a single workspace file by its
* workspace-relative path. Shared by the file explorer tree and the git diff
* pane so both surfaces download through the same host token + download-store
* pipeline instead of duplicating the plumbing.
* pane so both surfaces download through the same download-store pipeline instead
* of duplicating the plumbing: the HTTP token download over a direct TCP connection,
* the session socket over anything else.
*/
export function useFileDownload({
serverId,
@@ -30,7 +31,7 @@ export function useFileDownload({
() => workspaceId?.trim() || normalizedWorkspaceRoot,
[normalizedWorkspaceRoot, workspaceId],
);
const { requestFileDownloadToken } = useFileExplorerActions({
const { requestFileDownloadToken, requestFileBytes } = useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
@@ -48,9 +49,18 @@ export function useFileDownload({
fileName,
path,
daemonProfile,
activeConnectionId: getHostRuntimeStore().getSnapshot(serverId)?.activeConnectionId ?? null,
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
readFile: requestFileBytes,
});
},
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
[
daemonProfile,
requestFileBytes,
requestFileDownloadToken,
serverId,
startDownload,
workspaceScopeId,
],
);
}
@@ -253,6 +253,21 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
[client, normalizedWorkspaceRoot, t],
);
const requestFileBytes = useCallback(
async (path: string, maxBytes: number) => {
if (!normalizedWorkspaceRoot) {
throw new Error(t("workspace.fileExplorer.states.unavailable"));
}
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
// No request timeout: a large file over a slow relay legitimately takes longer than the
// 60 s default, and a dead connection still rejects on disconnect.
return client.readFile(normalizedWorkspaceRoot, path, undefined, maxBytes, 0);
},
[client, normalizedWorkspaceRoot, t],
);
const createEntry = useCallback(
async (input: { parentPath: string; name: string; kind: "file" | "directory" }) => {
if (!client || !normalizedWorkspaceRoot) {
@@ -342,6 +357,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
requestFileBytes,
createEntry,
renameEntry,
duplicateEntry,
@@ -15,6 +15,7 @@ import {
parseClampedFontSize,
parseTerminalScrollbackLines,
resolveContentMaxWidth,
sanitizeFontFamily,
saveAppSettings,
type SettingsDeps,
} from "./storage";
@@ -1094,3 +1095,34 @@ describe("content max width", () => {
expect((await load("wide")).contentMaxWidth).toBeNull();
});
});
describe("font family CSS safety", () => {
it.each(["'foo", '"foo', "Menlo, 'foo", "foo\\", "foo/*", "foo(", "foo["])(
"rejects unfinished CSS syntax: %s",
(value) => expect(sanitizeFontFamily(value)).toBeNull(),
);
it.each([
"Menlo",
"'Fira Code', monospace",
'"Fira Code", monospace',
"'Font\\'s name'",
"Font\\ Name",
"'Font (Mono)'",
"'Font [Mono]'",
])("preserves complete font families: %s", (value) =>
expect(sanitizeFontFamily(value)).toBe(value),
);
it.each(["uiFontFamily", "monoFontFamily"])(
"recovers a persisted unmatched quote in %s",
async (field) => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ [field]: "'foo" }),
}),
});
expect(await loadAppSettingsFromStorage(deps)).toMatchObject({ [field]: "" });
},
);
});
+18 -1
View File
@@ -522,7 +522,24 @@ export function sanitizeFontFamily(value: unknown): string | null {
if ([...trimmed].some((char) => char.charCodeAt(0) <= 0x1f)) {
return null; // control chars would corrupt the font-family string
}
return trimmed; // quotes/commas are legit in stacks
// Unistyles serializes theme values into one shared stylesheet. An unfinished
// string, escape, comment, or block consumes the declarations that follow.
let quote: string | null = null;
let escaped = false;
for (const char of trimmed) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (quote !== null) {
if (char === quote) quote = null;
} else if (char === "'" || char === '"') {
quote = char;
} else if (/[()[\]/*]/.test(char)) {
return null;
}
}
return quote === null && !escaped ? trimmed : null;
}
async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): Promise<{
+1 -1
View File
@@ -631,7 +631,7 @@ export const ar: TranslationResources = {
},
switcher: {
trigger: "تبديل علامات التبويب (فتح{{count}})",
title: "علامة التبويب التبديل",
title: "علامات تبويب الـ Workspace",
searchPlaceholder: "علامات تبويب البحث",
},
menu: {
+1 -1
View File
@@ -628,7 +628,7 @@ export const en = {
},
switcher: {
trigger: "Switch tabs ({{count}} open)",
title: "Switch tab",
title: "Workspace tabs",
searchPlaceholder: "Search tabs",
},
menu: {
+1 -1
View File
@@ -637,7 +637,7 @@ export const es: TranslationResources = {
},
switcher: {
trigger: "Cambiar pestañas ({{count}}abierto)",
title: "Cambiar pestaña",
title: "Pestañas del workspace",
searchPlaceholder: "Pestañas de búsqueda",
},
menu: {
+1 -1
View File
@@ -636,7 +636,7 @@ export const fr: TranslationResources = {
},
switcher: {
trigger: "Changer d’onglet ({{count}} ouverts)",
title: "Changer d’onglet",
title: "Onglets de l’espace de travail",
searchPlaceholder: "Rechercher des onglets",
},
menu: {
+1 -1
View File
@@ -637,7 +637,7 @@ export const ja: TranslationResources = {
},
switcher: {
trigger: "タブを切り替え({{count}}件開いています)",
title: "タブを切り替え",
title: "ワークスペースのタブ",
searchPlaceholder: "タブを検索",
},
menu: {
+1 -1
View File
@@ -632,7 +632,7 @@ export const ko: TranslationResources = {
},
switcher: {
trigger: "탭 전환 ({{count}}개 열림)",
title: "탭 전환",
title: "워크스페이스 탭",
searchPlaceholder: "탭 검색",
},
menu: {
+1 -1
View File
@@ -636,7 +636,7 @@ export const ptBR: TranslationResources = {
},
switcher: {
trigger: "Alternar abas ({{count}} abertas)",
title: "Alternar aba",
title: "Abas do workspace",
searchPlaceholder: "Buscar abas",
},
menu: {
+1 -1
View File
@@ -638,7 +638,7 @@ export const ru: TranslationResources = {
},
switcher: {
trigger: "Переключить вкладки (открыто: {{count}})",
title: "Переключить вкладку",
title: "Вкладки рабочего пространства",
searchPlaceholder: "Поиск вкладок",
},
menu: {
+1 -1
View File
@@ -631,7 +631,7 @@ export const zhCN: TranslationResources = {
},
switcher: {
trigger: "切换标签(已打开 {{count}} 个)",
title: "切换标签",
title: "Workspace 标签",
searchPlaceholder: "搜索标签",
},
menu: {
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { buildAgentFactsLine, type AgentFactsInput } from "./agent-facts-line";
const models = [
{
provider: "claude",
id: "claude-fable-5-1",
label: "Fable 5.1",
thinkingOptions: [
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
],
},
];
const modes = [
{ id: "default", label: "Default" },
{ id: "bypassPermissions", label: "Bypass permissions" },
];
const base: AgentFactsInput = {
models,
runtimeModelId: "claude-fable-5-1",
configuredModelId: null,
runtimeThinkingOptionId: "high",
explicitThinkingOptionId: null,
availableModes: modes,
currentModeId: "bypassPermissions",
contextWindowUsedTokens: 56_200,
contextWindowMaxTokens: 200_000,
};
describe("buildAgentFactsLine", () => {
it("joins model, thinking, mode, and context usage as the controls show them", () => {
expect(buildAgentFactsLine(base)).toBe(
"Fable 5.1 · High · Bypass permissions · 56k / 200k tokens (28%)",
);
});
it("shows the raw model id before the catalog has loaded", () => {
expect(
buildAgentFactsLine({
...base,
models: null,
runtimeModelId: null,
configuredModelId: "gpt-6.1-sol",
runtimeThinkingOptionId: null,
explicitThinkingOptionId: "medium",
availableModes: [],
contextWindowUsedTokens: null,
contextWindowMaxTokens: null,
}),
).toBe("gpt-6.1-sol · Medium");
});
it("falls back to the provider's first mode until the agent reports one", () => {
expect(
buildAgentFactsLine({
...base,
currentModeId: null,
contextWindowUsedTokens: undefined,
contextWindowMaxTokens: undefined,
}),
).toBe("Fable 5.1 · High · Default");
});
it("leaves thinking and context out when the agent has none", () => {
expect(
buildAgentFactsLine({
...base,
models: [{ provider: "mock", id: "m", label: "Model M" }],
runtimeModelId: "m",
runtimeThinkingOptionId: null,
availableModes: [],
contextWindowUsedTokens: 10,
contextWindowMaxTokens: 0,
}),
).toBe("Model M");
});
it("is empty until the agent has a model", () => {
expect(
buildAgentFactsLine({ ...base, runtimeModelId: null, configuredModelId: " " }),
).toBeNull();
});
});
@@ -0,0 +1,59 @@
import type { AgentMode, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import { formatAgentModeLabel } from "@/agent-controls/labels";
import { formatTokenCount } from "@/components/context-window-meter.utils";
import { resolveAgentModelSelection } from "@/composer/agent-controls/utils";
export interface AgentFactsInput {
models: AgentModelDefinition[] | null;
runtimeModelId: string | null | undefined;
configuredModelId: string | null | undefined;
runtimeThinkingOptionId: string | null | undefined;
explicitThinkingOptionId: string | null | undefined;
availableModes: AgentMode[];
currentModeId: string | null | undefined;
contextWindowUsedTokens: number | null | undefined;
contextWindowMaxTokens: number | null | undefined;
}
/**
* What the composer's controls and context meter show about a Paseo agent, as one line: model,
* thinking, mode, and context usage, each resolved exactly as its control resolves it. Null until
* the agent has a model at all.
*/
export function buildAgentFactsLine(input: AgentFactsInput): string | null {
const hasModel = Boolean(input.runtimeModelId?.trim() || input.configuredModelId?.trim());
if (!hasModel) return null;
const selection = resolveAgentModelSelection(input);
const parts = [selection.displayModel];
if (selection.selectedThinkingId) parts.push(selection.displayThinking);
const mode = resolveModeLabel(input.availableModes, input.currentModeId);
if (mode) parts.push(mode);
const context = formatContextUsage(input.contextWindowUsedTokens, input.contextWindowMaxTokens);
if (context) parts.push(context);
return parts.join(" · ");
}
/** The mode control shows the current mode, or the provider's first one until the agent reports. */
function resolveModeLabel(modes: AgentMode[], currentModeId: string | null | undefined) {
const mode = modes.find((candidate) => candidate.id === currentModeId) ?? modes[0];
return mode ? formatAgentModeLabel(mode) : null;
}
/** The context meter's numbers: used of max, with the share it draws as the ring. */
function formatContextUsage(
usedTokens: number | null | undefined,
maxTokens: number | null | undefined,
): string | null {
if (
typeof usedTokens !== "number" ||
typeof maxTokens !== "number" ||
!Number.isFinite(usedTokens) ||
!Number.isFinite(maxTokens) ||
usedTokens < 0 ||
maxTokens <= 0
) {
return null;
}
const percentage = Math.round(Math.min(100, (usedTokens / maxTokens) * 100));
return `${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens (${percentage}%)`;
}
+55 -2
View File
@@ -27,6 +27,11 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { RetainedChatContent } from "./retained-chat-content";
import { Composer } from "@/composer";
import {
useComposerEditingSession,
useComposerFullscreen,
type ComposerEditingSession,
} from "@/composer/editing-session";
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
import {
resolveComposerTrackControlClearance,
@@ -55,6 +60,9 @@ import { useContainerWidthBelow } from "@/hooks/use-container-width";
import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state";
import { TimelineSyncStatus } from "@/timeline/sync-status";
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
import { buildAgentFactsLine } from "@/panels/agent-facts-line";
import { PaneSubtitle } from "@/panels/pane-subtitle";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
import { RenderProfile } from "@/utils/render-profiler";
import { useHasPluginComposerPills } from "@/plugins";
@@ -1155,6 +1163,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
const { t } = useTranslation();
// The workspace sheet is for looking; Open as tab brings the agent back with its composer.
const showsComposer = usePaneContext().host !== "sheet";
// The dock lays out fullscreen for the same session the composer edits in.
const composerEditingSession = useComposerEditingSession("chat");
const isComposerFullscreen = useComposerFullscreen(composerEditingSession);
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
state.sessions[serverId]?.agentTasks.get(agentId),
@@ -1229,6 +1240,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
cwd={cwd}
isSubmitLoading={false}
agentInputDraft={agentInputDraft}
editingSession={composerEditingSession}
onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend}
onComposerHeightChange={handleComposerHeightChange}
@@ -1238,6 +1250,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
) : null;
const streamContent = (
<View style={animatedStaticStyles.content}>
{showsComposer ? null : <SheetAgentFacts serverId={serverId} agentId={agentId} />}
<RenderProfile id={`AgentStreamSection:${agentId}`}>
<AgentStreamSection
streamViewRef={streamViewRef}
@@ -1292,7 +1305,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
);
const dock = (
<ChatSurface disabled={isArchivingCurrentAgent}>
<ChatSurface disabled={isArchivingCurrentAgent} fullscreen={isComposerFullscreen}>
{dockContent}
{composerSection}
{dockOverlay}
@@ -1323,13 +1336,15 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
function ChatSurface({
children,
disabled,
fullscreen,
}: {
children: [ReactNode, ReactNode, ReactNode];
disabled: boolean;
fullscreen: boolean;
}) {
return (
<FileDropZone style={styles.container} disabled={disabled}>
<ComposerDock>{children}</ComposerDock>
<ComposerDock fullscreen={fullscreen}>{children}</ComposerDock>
</FileDropZone>
);
}
@@ -1364,6 +1379,36 @@ function TimelineSyncErrorCallout({
);
}
/**
* What the composer's controls and context meter would show, as one line, for an agent shown
* without its composer.
*/
function SheetAgentFacts({ serverId, agentId }: { serverId: string; agentId: string }) {
const agent = useSessionStore(
useShallow((state) => {
const current = state.sessions[serverId]?.agents.get(agentId);
return current
? {
provider: current.provider,
cwd: current.cwd,
runtimeModelId: current.runtimeInfo?.model ?? null,
configuredModelId: current.model ?? null,
runtimeThinkingOptionId: current.runtimeInfo?.thinkingOptionId ?? null,
explicitThinkingOptionId: current.thinkingOptionId ?? null,
availableModes: current.availableModes,
currentModeId: current.currentModeId,
contextWindowUsedTokens: current.lastUsage?.contextWindowUsedTokens ?? null,
contextWindowMaxTokens: current.lastUsage?.contextWindowMaxTokens ?? null,
}
: null;
}),
);
const { entries } = useProvidersSnapshot(serverId, { cwd: agent?.cwd });
const models = entries?.find((entry) => entry.provider === agent?.provider)?.models ?? null;
const text = agent ? buildAgentFactsLine({ ...agent, models }) : null;
return text ? <PaneSubtitle text={text} testID="agent-pane-subtitle" /> : null;
}
const AgentStreamSection = memo(function AgentStreamSection({
streamViewRef,
serverId,
@@ -1473,6 +1518,7 @@ const AgentComposerSection = memo(function AgentComposerSection({
cwd,
isSubmitLoading,
agentInputDraft,
editingSession,
onAttentionInputFocus,
onAttentionPromptSend,
onComposerHeightChange,
@@ -1486,6 +1532,7 @@ const AgentComposerSection = memo(function AgentComposerSection({
cwd: string;
isSubmitLoading: boolean;
agentInputDraft: AgentInputDraft;
editingSession: ComposerEditingSession;
onAttentionInputFocus: () => void;
onAttentionPromptSend: () => void;
onComposerHeightChange: (height: number) => void;
@@ -1509,6 +1556,7 @@ const AgentComposerSection = memo(function AgentComposerSection({
cwd={cwd}
isSubmitLoading={isSubmitLoading}
agentInputDraft={agentInputDraft}
editingSession={editingSession}
onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend}
onComposerHeightChange={onComposerHeightChange}
@@ -1524,6 +1572,7 @@ function ActiveAgentComposer({
cwd,
isSubmitLoading,
agentInputDraft,
editingSession,
onAttentionInputFocus,
onAttentionPromptSend,
onComposerHeightChange,
@@ -1535,6 +1584,7 @@ function ActiveAgentComposer({
cwd: string;
isSubmitLoading: boolean;
agentInputDraft: AgentInputDraft;
editingSession: ComposerEditingSession;
onAttentionInputFocus: () => void;
onAttentionPromptSend: () => void;
onComposerHeightChange: (height: number) => void;
@@ -1631,6 +1681,7 @@ function ActiveAgentComposer({
onChangeAttachments={agentInputDraft.setAttachments}
cwd={cwd}
clearDraft={agentInputDraft.clear}
editingSession={editingSession}
autoFocus
autoFocusKey={String(agentInputDraft.attachmentFocusRequestId)}
isSubmitLoading={isSubmitLoading}
@@ -1723,6 +1774,8 @@ const animatedStaticStyles = RNStyleSheet.create({
inputAreaWrapper: {
width: "100%",
flexShrink: 1,
// Content-sized inline; fills the dock's fullscreen slot.
flexGrow: 1,
},
});
+3
View File
@@ -12,6 +12,7 @@ import {
type ArchiveFinishedStatus,
useArchiveSubagent,
useDetachSubagent,
useSubagentOpensInSheet,
type SubagentRow,
} from "@/subagents";
import { SubagentsTrack } from "@/subagents/track";
@@ -90,6 +91,7 @@ export const AgentTracks = memo(function AgentTracks({
},
[openSubagentTarget],
);
const opensInSheet = useSubagentOpensInSheet(workspaceKey);
const handleOpenChanges = useCallback(() => {
if (!workspaceKey) {
return;
@@ -122,6 +124,7 @@ export const AgentTracks = memo(function AgentTracks({
rows={subagentRows}
onOpenSubagent={handleOpenSubagent}
onOpenProviderSubagent={handleOpenProviderSubagent}
opensInSheet={opensInSheet}
onArchiveSubagent={archiveSubagent}
onArchiveFinished={onArchiveFinished}
archiveFinishedStatus={archiveFinishedStatus}
+30
View File
@@ -0,0 +1,30 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
/**
* One muted line of facts about a pane's agent, under the pane's header and above its stream:
* model, thinking, tokens. Provider subagents get it from the daemon; a Paseo agent shown without
* its composer gets it from the same facts the composer's controls show.
*/
export function PaneSubtitle({ text, testID }: { text: string; testID?: string }) {
return (
<View style={styles.header}>
<Text style={styles.text} numberOfLines={1} testID={testID}>
{text}
</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
header: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
text: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));
@@ -13,9 +13,11 @@ import { ComposerTrackBar } from "@/composer/tracks";
import { useIsCompactFormFactor } from "@/constants/layout";
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { usePaneContext } from "@/panels/pane-context";
import { PaneSubtitle } from "@/panels/pane-subtitle";
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
import { useSessionStore } from "@/stores/session-store";
import { useSubagentsForParent } from "@/subagents/select";
import { type SubagentRow, useSubagentsForParent } from "@/subagents/select";
import { useSubagentOpensInSheet } from "@/subagents/opens-in-sheet";
import { SubagentsTrack } from "@/subagents/track";
import {
providerSubagentKey,
@@ -30,6 +32,7 @@ import type { StreamItem } from "@/types/stream";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
import type { TurnPresentation } from "@/timeline/turn-liveness";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
const EMPTY_PERMISSIONS = new Map<string, PendingPermission>();
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
@@ -47,10 +50,12 @@ function ProviderSubagentChildTrack({
serverId,
rows,
onOpenProviderSubagent,
opensInSheet,
}: {
serverId: string;
rows: ReturnType<typeof useSubagentsForParent>;
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
opensInSheet: (row: SubagentRow) => boolean;
}) {
if (rows.length === 0) return null;
return (
@@ -60,6 +65,7 @@ function ProviderSubagentChildTrack({
rows={rows}
onOpenSubagent={NOOP_SUBAGENT}
onOpenProviderSubagent={onOpenProviderSubagent}
opensInSheet={opensInSheet}
onArchiveSubagent={NOOP_SUBAGENT}
/>
</ComposerTrackBar>
@@ -109,7 +115,7 @@ function useProviderSubagentDescriptor(
function ProviderSubagentPanel() {
const { t } = useTranslation();
const { serverId, target, openFileInWorkspace, openTab } = usePaneContext();
const { serverId, workspaceId, target, openFileInWorkspace, openTab } = usePaneContext();
invariant(target.kind === "provider_subagent", "ProviderSubagentPanel requires provider target");
const key = providerSubagentKey(serverId, target.parentAgentId, target.subagentId);
const streamId = `provider:${encodeURIComponent(target.parentAgentId)}:${encodeURIComponent(target.subagentId)}`;
@@ -137,6 +143,9 @@ function ProviderSubagentPanel() {
providerParentSubagentId: target.subagentId,
});
const childTrackClearance = resolveChildTrackClearance(childRows.length, isCompact);
const opensInSheet = useSubagentOpensInSheet(
buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
);
const openProviderChild = useCallback(
(parentAgentId: string, subagentId: string) => {
openTab({ kind: "provider_subagent", parentAgentId, subagentId });
@@ -243,17 +252,7 @@ function ProviderSubagentPanel() {
return (
<View style={styles.container} testID="provider-subagent-panel">
{subtitle ? (
<View style={styles.subtitleHeader}>
<Text
style={styles.subtitleText}
numberOfLines={1}
testID="provider-subagent-pane-subtitle"
>
{subtitle}
</Text>
</View>
) : null}
{subtitle ? <PaneSubtitle text={subtitle} testID="provider-subagent-pane-subtitle" /> : null}
<AgentStreamView
agentId={streamId}
serverId={serverId}
@@ -273,6 +272,7 @@ function ProviderSubagentPanel() {
serverId={serverId}
rows={childRows}
onOpenProviderSubagent={openProviderChild}
opensInSheet={opensInSheet}
/>
</View>
);
@@ -280,16 +280,6 @@ function ProviderSubagentPanel() {
const styles = StyleSheet.create((theme) => ({
container: { flex: 1, minHeight: 0 },
subtitleHeader: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
},
subtitleText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
unsupported: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 },
unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" },
}));
@@ -17,6 +17,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native";
import { Composer } from "@/composer";
import { ComposerDock } from "@/composer/dock";
import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import {
resolveComposerAttachmentSubmitFormat,
@@ -1636,6 +1637,8 @@ export function NewWorkspaceScreen({
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const toast = useToast();
const composerEditing = useNewWorkspaceComposerEditing();
const { leaveFullscreen } = composerEditing;
const mergeWorkspaces = useCallback(
(targetServerId: string, workspaces: Iterable<WorkspaceDescriptor>) => {
getHostRuntimeStore().acceptWorkspaceSnapshots(targetServerId, Array.from(workspaces));
@@ -2121,6 +2124,7 @@ export function NewWorkspaceScreen({
return;
}
outcome = "navigated";
leaveFullscreen();
navigateToWorkspace({ serverId: targetServerId, workspaceId });
},
});
@@ -2171,6 +2175,7 @@ export function NewWorkspaceScreen({
forkDraftSetup,
isStillOnCreateScreen,
launchTarget,
leaveFullscreen,
selectedServerId,
supportsForgeSearch,
t,
@@ -2227,6 +2232,7 @@ export function NewWorkspaceScreen({
return;
}
outcome = "navigated";
leaveFullscreen();
navigateToWorkspace({ serverId: targetServerId, workspaceId, target });
},
});
@@ -2250,6 +2256,7 @@ export function NewWorkspaceScreen({
t,
terminalPromptText,
toast,
leaveFullscreen,
updateFormPreferences,
withConnectedClient,
]);
@@ -2369,10 +2376,12 @@ export function NewWorkspaceScreen({
const importSession = useImportSession({ serverId: selectedServerId });
const isComposerFullscreen = composerEditing.isFullscreen(isTerminalLaunch);
const composer = isTerminalLaunch ? (
<Composer
key="terminal"
inputMode="terminal"
editingSession={composerEditing.terminal}
readOnly={!terminalTakesPrompt}
placeholder={terminalPlaceholder}
submitLabel={terminalSubmitLabel}
@@ -2399,6 +2408,7 @@ export function NewWorkspaceScreen({
) : (
<Composer
key="chat"
editingSession={composerEditing.chat}
agentId={draftKey}
serverId={selectedServerId}
isPaneFocused={true}
@@ -2429,32 +2439,53 @@ export function NewWorkspaceScreen({
);
return (
<FileDropZone style={styles.container}>
<MenuHeader borderless />
<View style={styles.content}>
<TitlebarDragRegion />
<NewWorkspaceLayout
isCompact={isCompact}
title={t("newWorkspace.title")}
formStack={formStack}
onImportSession={importSession.open}
>
{composer}
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</NewWorkspaceLayout>
</View>
<NewWorkspaceLayout
isCompact={isCompact}
fullscreen={isComposerFullscreen}
title={t("newWorkspace.title")}
formStack={formStack}
onImportSession={importSession.open}
>
{composer}
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</NewWorkspaceLayout>
{importSession.sheet}
</FileDropZone>
);
}
/** Terminal and chat launches edit in separate sessions; the screen follows the mounted one. */
function useNewWorkspaceComposerEditing() {
const terminal = useComposerEditingSession("terminal");
const chat = useComposerEditingSession("chat");
const isTerminalFullscreen = useComposerFullscreen(terminal);
const isChatFullscreen = useComposerFullscreen(chat);
// The sessions outlive the screen (its draft keeps them), so a launch that leaves
// the screen also leaves fullscreen; the next draft starts inline.
const leaveFullscreen = useCallback(() => {
terminal.setFullscreen(false);
chat.setFullscreen(false);
}, [chat, terminal]);
return {
terminal,
chat,
leaveFullscreen,
isFullscreen: (isTerminalLaunch: boolean) =>
isTerminalLaunch ? isTerminalFullscreen : isChatFullscreen,
};
}
function NewWorkspaceLayout({
isCompact,
fullscreen,
title,
formStack,
onImportSession,
children,
}: {
isCompact: boolean;
/** The composer covers the screen; the header leaves and the dock fills the content. */
fullscreen: boolean;
title: string;
formStack: ReactNode;
onImportSession: () => void;
@@ -2472,13 +2503,19 @@ function NewWorkspaceLayout({
</>
);
return (
<ComposerDock centered={!isCompact}>
{setupFields}
<>
{children}
{isCompact ? null : importSessionButton}
</>
</ComposerDock>
<>
{fullscreen ? null : <MenuHeader borderless />}
<View style={styles.content}>
<TitlebarDragRegion />
<ComposerDock centered={!isCompact} fullscreen={fullscreen}>
{setupFields}
<>
{children}
{isCompact || fullscreen ? null : importSessionButton}
</>
</ComposerDock>
</View>
</>
);
}
@@ -241,7 +241,7 @@ interface FontFamilyRowProps {
draft: string;
withBorder: boolean;
onChangeDraft: (value: string) => void;
onCommit: (value: string) => void;
onCommit: (value: string) => string;
}
function FontFamilyRow({
@@ -255,13 +255,16 @@ function FontFamilyRow({
onChangeDraft,
onCommit,
}: FontFamilyRowProps) {
const inputRef = useRef<EditingTextInputHandle>(null);
const handleCommit = useCallback(() => {
onCommit(draft);
const committed = onCommit(draft);
inputRef.current?.replaceText(committed);
}, [draft, onCommit]);
// Resync from the committed value when it changes elsewhere.
useEffect(() => {
onChangeDraft(value);
inputRef.current?.replaceText(value);
// Only resync on external value changes, not on local keystrokes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
@@ -273,6 +276,7 @@ function FontFamilyRow({
<Text style={settingsStyles.rowHint}>{hint}</Text>
</View>
<TextInput
ref={inputRef}
initialValue={draft}
onChangeText={onChangeDraft}
onBlur={handleCommit}
@@ -533,12 +537,13 @@ export function AppearanceSection() {
const sanitized = sanitizeFontFamily(value);
if (sanitized === null) {
setUiFontDraft(settings.uiFontFamily);
return;
return settings.uiFontFamily;
}
setUiFontDraft(sanitized);
if (sanitized !== settings.uiFontFamily) {
void updateSettings({ uiFontFamily: sanitized });
}
return sanitized;
},
[settings.uiFontFamily, updateSettings],
);
@@ -548,12 +553,13 @@ export function AppearanceSection() {
const sanitized = sanitizeFontFamily(value);
if (sanitized === null) {
setMonoFontDraft(settings.monoFontFamily);
return;
return settings.monoFontFamily;
}
setMonoFontDraft(sanitized);
if (sanitized !== settings.monoFontFamily) {
void updateSettings({ monoFontFamily: sanitized });
}
return sanitized;
},
[settings.monoFontFamily, updateSettings],
);
@@ -80,6 +80,7 @@ vi.mock("@gorhom/bottom-sheet", async () => {
await vi.importActual<typeof import("react-native")>("react-native");
return {
BottomSheetBackdrop: () => null,
BottomSheetHandle: () => null,
BottomSheetModal: ReactModule.forwardRef(() => null),
BottomSheetScrollView: ScrollView,
BottomSheetTextInput: TextInput,

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