mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-15 13:52:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70e475f9b7 | ||
|
|
1c990715b2 | ||
|
|
a96c892302 | ||
|
|
74282dac12 | ||
|
|
12066431ad | ||
|
|
4d8bed9ddd | ||
|
|
b2ca7dcf03 | ||
|
|
8b1ba3ca58 | ||
|
|
e5124b039a | ||
|
|
5c108f345c | ||
|
|
9ab99a6e1e | ||
|
|
8ff2424da1 | ||
|
|
6db47b3c37 | ||
|
|
73221ce5bd | ||
|
|
6ad124758c | ||
|
|
f3ddf99fe3 | ||
|
|
404d08d24b | ||
|
|
6d43a8c5b0 | ||
|
|
e1a5664d9b | ||
|
|
b58af43a06 | ||
|
|
1bf3a57a1c | ||
|
|
56448842f0 | ||
|
|
14e6384516 | ||
|
|
cafa04d3fb | ||
|
|
938c4e2632 | ||
|
|
d57096a456 | ||
|
|
ada7ba43f0 |
+13
-4
@@ -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
@@ -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 홈페이지 및 문서에 게재할 수 있습니다.
|
||||
|
||||
## 관련 프로젝트
|
||||
|
||||
|
||||
@@ -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
@@ -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 的首页和文档中。
|
||||
|
||||
## 相关项目
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: `,
|
||||
`Emit settled assistant image Markdown: `,
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
+30
-25
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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" },
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]: "" });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -631,7 +631,7 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
switcher: {
|
||||
trigger: "تبديل علامات التبويب (فتح{{count}})",
|
||||
title: "علامة التبويب التبديل",
|
||||
title: "علامات تبويب الـ Workspace",
|
||||
searchPlaceholder: "علامات تبويب البحث",
|
||||
},
|
||||
menu: {
|
||||
|
||||
@@ -628,7 +628,7 @@ export const en = {
|
||||
},
|
||||
switcher: {
|
||||
trigger: "Switch tabs ({{count}} open)",
|
||||
title: "Switch tab",
|
||||
title: "Workspace tabs",
|
||||
searchPlaceholder: "Search tabs",
|
||||
},
|
||||
menu: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -637,7 +637,7 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
switcher: {
|
||||
trigger: "タブを切り替え({{count}}件開いています)",
|
||||
title: "タブを切り替え",
|
||||
title: "ワークスペースのタブ",
|
||||
searchPlaceholder: "タブを検索",
|
||||
},
|
||||
menu: {
|
||||
|
||||
@@ -632,7 +632,7 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
switcher: {
|
||||
trigger: "탭 전환 ({{count}}개 열림)",
|
||||
title: "탭 전환",
|
||||
title: "워크스페이스 탭",
|
||||
searchPlaceholder: "탭 검색",
|
||||
},
|
||||
menu: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -638,7 +638,7 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
switcher: {
|
||||
trigger: "Переключить вкладки (открыто: {{count}})",
|
||||
title: "Переключить вкладку",
|
||||
title: "Вкладки рабочего пространства",
|
||||
searchPlaceholder: "Поиск вкладок",
|
||||
},
|
||||
menu: {
|
||||
|
||||
@@ -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}%)`;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user