mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a05491be5 | ||
|
|
28bb7e29be | ||
|
|
d6d04b0c56 | ||
|
|
20b2dad610 | ||
|
|
58ca9a9b30 | ||
|
|
dfa9977047 | ||
|
|
4f46e094a1 | ||
|
|
dd914c1479 | ||
|
|
025e7ef1d8 | ||
|
|
b4e837a9fe | ||
|
|
f098f77f4a | ||
|
|
f59ebd4fd5 |
+11
-18
@@ -133,11 +133,12 @@ jobs:
|
||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
# The locked Playwright installers do not support ubuntu-latest's Ubuntu 26.04.
|
||||
typecheck:
|
||||
name: typecheck
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -395,7 +396,7 @@ jobs:
|
||||
name: app-tests
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -417,19 +418,11 @@ jobs:
|
||||
- name: Run app unit tests
|
||||
run: npm run test --workspace=@getpaseo/app
|
||||
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Run Android module tests
|
||||
run: npm run test:android --workspace=@getpaseo/app
|
||||
|
||||
playwright-1:
|
||||
name: playwright (shard 1/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "1/8"
|
||||
PLAYWRIGHT_ARTIFACT: "1"
|
||||
@@ -485,7 +478,7 @@ jobs:
|
||||
name: playwright (shard 2/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "2/8"
|
||||
PLAYWRIGHT_ARTIFACT: "2"
|
||||
@@ -495,7 +488,7 @@ jobs:
|
||||
name: playwright (shard 3/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "3/8"
|
||||
PLAYWRIGHT_ARTIFACT: "3"
|
||||
@@ -505,7 +498,7 @@ jobs:
|
||||
name: playwright (shard 4/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "4/8"
|
||||
PLAYWRIGHT_ARTIFACT: "4"
|
||||
@@ -515,7 +508,7 @@ jobs:
|
||||
name: playwright (shard 5/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "5/8"
|
||||
PLAYWRIGHT_ARTIFACT: "5"
|
||||
@@ -525,7 +518,7 @@ jobs:
|
||||
name: playwright (shard 6/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "6/8"
|
||||
PLAYWRIGHT_ARTIFACT: "6"
|
||||
@@ -535,7 +528,7 @@ jobs:
|
||||
name: playwright (shard 7/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "7/8"
|
||||
PLAYWRIGHT_ARTIFACT: "7"
|
||||
@@ -545,7 +538,7 @@ jobs:
|
||||
name: playwright (shard 8/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "8/8"
|
||||
PLAYWRIGHT_ARTIFACT: "8"
|
||||
|
||||
+6
-3
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 0.14.0-beta.1 - 2026-10-27
|
||||
## 0.14.0-beta.2 - 2026-10-28
|
||||
|
||||
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||
|
||||
@@ -11,7 +11,7 @@ Voice settings now let you configure speech providers and download local models
|
||||
### Added
|
||||
|
||||
- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938))
|
||||
- Added collapse and expand controls for completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
||||
- Added Chat display settings that group tool calls or collapse completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026), [#8042](https://github.com/getpaseo/paseo/pull/8042), [#8109](https://github.com/getpaseo/paseo/pull/8109) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
||||
- Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983))
|
||||
- Added downloadable local speech models in Voice settings, including SenseVoice and Whisper for Chinese dictation ([#7295](https://github.com/getpaseo/paseo/pull/7295))
|
||||
- Added a choice of Enter or ⌘/Ctrl+Enter to send messages ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
|
||||
@@ -23,6 +23,7 @@ Voice settings now let you configure speech providers and download local models
|
||||
|
||||
- Release eligible agent runtimes after one idle hour, resuming the saved session on the next prompt ([#7782](https://github.com/getpaseo/paseo/pull/7782))
|
||||
- Added a link back to the parent in subagent panes ([#7784](https://github.com/getpaseo/paseo/pull/7784))
|
||||
- Opened subagents in place of the subagent list on phones, with Back returning to the list ([#8093](https://github.com/getpaseo/paseo/pull/8093))
|
||||
- Added clickable links and selectable descriptions to question cards ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
|
||||
- Kept large Import session lists responsive on web ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
|
||||
- Enlarged project and workspace menu buttons on phones ([#7928](https://github.com/getpaseo/paseo/pull/7928))
|
||||
@@ -48,10 +49,12 @@ Voice settings now let you configure speech providers and download local models
|
||||
- Fixed outside-folder links replacing Files with an access error ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
|
||||
- Fixed command suggestions overlapping a growing Composer on phones ([#7910](https://github.com/getpaseo/paseo/pull/7910))
|
||||
- Fixed bare Markdown URLs losing ports or stopping at colons in paths ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
|
||||
- Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889))
|
||||
- Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889), [#8088](https://github.com/getpaseo/paseo/pull/8088))
|
||||
- Fixed compact navigation sizes and a cramped Settings detail pane in docked windows 600–719 wide ([#8052](https://github.com/getpaseo/paseo/pull/8052) by [@zeyugao](https://github.com/zeyugao))
|
||||
- Fixed Settings losing the selected host or sidebar scroll position between sections ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
|
||||
- Fixed OpenCode v2 subagent rows missing model and token details ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
|
||||
- Fixed file-link tooltips staying over the preview after leaving a chat ([#7842](https://github.com/getpaseo/paseo/pull/7842))
|
||||
- Fixed model discovery errors touching the model picker edges ([#8039](https://github.com/getpaseo/paseo/pull/8039) by [@ilteoood](https://github.com/ilteoood))
|
||||
- Fixed model selectors staying open over Settings ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
|
||||
|
||||
### Notes
|
||||
|
||||
+6
-3
@@ -1,6 +1,6 @@
|
||||
# 更新日志
|
||||
|
||||
## 0.14.0-beta.1 - 2026-10-27
|
||||
## 0.14.0-beta.2 - 2026-10-28
|
||||
|
||||
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
### 新增
|
||||
|
||||
- 插件现在可以为特定 Agent 提供工具 ([#7938](https://github.com/getpaseo/paseo/pull/7938))
|
||||
- 新增已完成对话轮次的折叠和展开功能 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
||||
- 新增“聊天显示”设置,可将工具调用分组或折叠已完成的对话轮次 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026), [#8042](https://github.com/getpaseo/paseo/pull/8042), [#8109](https://github.com/getpaseo/paseo/pull/8109) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
|
||||
- 插件现在可以在每次打开 Agent 会话时添加 MCP 服务器 ([#7983](https://github.com/getpaseo/paseo/pull/7983))
|
||||
- 语音设置新增可下载的本地语音模型,支持使用 SenseVoice 和 Whisper 进行中文听写 ([#7295](https://github.com/getpaseo/paseo/pull/7295))
|
||||
- 新增 Enter 或 ⌘/Ctrl+Enter 发送消息的快捷键选项 ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
- 符合条件的 Agent 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782))
|
||||
- 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784))
|
||||
- 在手机上从子 Agent 列表打开子 Agent 时直接替换列表,点按“返回”可回到列表 ([#8093](https://github.com/getpaseo/paseo/pull/8093))
|
||||
- 问题卡片新增可点击链接和可选中的描述文本 ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
|
||||
- 减少网页端长会话导入列表的操作卡顿 ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
|
||||
- 增大手机上项目和工作区菜单按钮的点击区域 ([#7928](https://github.com/getpaseo/paseo/pull/7928))
|
||||
@@ -48,10 +49,12 @@
|
||||
- 修复工作区外的文件夹链接使 Files 显示访问错误的问题 ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
|
||||
- 修复手机上命令建议与不断增高的 Composer 重叠的问题 ([#7910](https://github.com/getpaseo/paseo/pull/7910))
|
||||
- 修复 Markdown 裸链接丢失端口或在路径冒号处截断的问题 ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
|
||||
- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889))
|
||||
- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889), [#8088](https://github.com/getpaseo/paseo/pull/8088))
|
||||
- 修复宽度 600–719 的停靠布局使用紧凑导航尺寸、设置详情面板过窄的问题 ([#8052](https://github.com/getpaseo/paseo/pull/8052) by [@zeyugao](https://github.com/zeyugao))
|
||||
- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
|
||||
- 修复 OpenCode v2 子 Agent 行缺少模型和 token 详情的问题 ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
|
||||
- 修复离开聊天后文件链接提示仍遮挡预览的问题 ([#7842](https://github.com/getpaseo/paseo/pull/7842))
|
||||
- 修复模型发现错误信息紧贴模型选择器边缘的问题 ([#8039](https://github.com/getpaseo/paseo/pull/8039) by [@ilteoood](https://github.com/ilteoood))
|
||||
- 修复模型选择器在打开设置后仍然显示的问题 ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
|
||||
|
||||
### 说明
|
||||
|
||||
@@ -288,7 +288,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 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. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. 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) instead, replacing the subagents list in place (Back returns to the list); 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. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. 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.
|
||||
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ The older `android`, `android:development`, and `ios` commands remain aliases fo
|
||||
`android:production` still builds and installs a release through Expo; use `build:apk` when you
|
||||
need an artifact. `android:clear` removes the generated Android project.
|
||||
|
||||
Run `npm run test:android --workspace=@getpaseo/app` to prebuild Android and run native module unit tests. The app CI job runs this command with the Android toolchain.
|
||||
Run `npm run test:android --workspace=@getpaseo/app` explicitly with a local Android toolchain to prebuild Android and run native module unit tests. Ordinary CI does not prebuild Android or run these tests.
|
||||
|
||||
### Diagnostic logs without ADB
|
||||
|
||||
|
||||
+6
-4
@@ -150,18 +150,20 @@ One row off the rail makes the whole card look unconsidered.
|
||||
|
||||
Compact-first. The small case is designed; the large case adds chrome around it.
|
||||
|
||||
The list+detail pattern is canonical and reused across surfaces. The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) implement it identically:
|
||||
Use `md: 600` for both the compact/wide layout boundary and responsive density, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint. Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window.
|
||||
|
||||
The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) share the canonical list+detail navigation pattern:
|
||||
|
||||
- On compact: full-screen list with `<BackHeader>` at the top. Tapping a row pushes a full-screen detail with its own `<BackHeader>` that returns to the list.
|
||||
- On desktop: a 320px sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
|
||||
- On wide layouts: a sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
|
||||
|
||||
The branching is one `useIsCompactFormFactor()` check at the top of the screen component. The list and the detail are the same components in both layouts; only the framing changes.
|
||||
|
||||
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
|
||||
|
||||
On a narrow desktop route, app navigation yields to the rendered content topology when the remaining width cannot preserve its center target: Settings keeps its 320px list + 400px detail split, and a workspace Explorer keeps its current visible width plus a 400px center pane. That is a topology decision at the app container, not a second compact breakpoint. Temporary width clamps are render-only; widening restores the user's saved sidebar widths.
|
||||
On a narrow window using the wide layout, app navigation yields when the remaining width cannot accommodate the content topology. Settings prefers a 720px split (320px list + 400px detail); its list shrinks first to keep forms readable without reducing the detail below 400px. Explorer reserves at least 240px plus a 360px workspace. These are local space budgets, not compact breakpoints. The app sidebar shrinks from its preferred 320px toward a 200px minimum to leave a 400px center target; its preferred width fits at 720px. Temporary width clamps are render-only; widening restores the user's saved sidebar widths.
|
||||
|
||||
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
|
||||
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf.
|
||||
|
||||
Windows and Linux controls are fixed window chrome, outside scrolling header content. A tab rail that reaches them ends at their obstruction and shows the shared overflow fade. On macOS, the Explorer toggle occupies a fixed top-right window slot so opening and closing Explorer does not move the pointer target.
|
||||
|
||||
|
||||
+24
-13
@@ -33,25 +33,36 @@ keeps only agents and terminals. A panel kind opts in with `compactSheet` in its
|
||||
(`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. 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.
|
||||
other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet lands on
|
||||
top of it, and closing the top sheet (drag, Close, backdrop, or Android Back) uncovers the one below.
|
||||
|
||||
A subagent chosen from the subagents list is the exception. It opens as a page of the list's own
|
||||
sheet and replaces the list in place: Back returns to the list, Close ends both, and Open parent
|
||||
closes the list, landing on the pane the list was opened from. The store's `selectSheetTab` describes
|
||||
the `SheetTab` a reveal would show without opening it, so the page never enters `sheetsByWorkspace`.
|
||||
See [menus.md](menus.md#a-page-that-fills-the-sheet) for the page contract.
|
||||
|
||||
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 both compact sidebars as well as the workspace. 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. Compact sheets use the app overlay host: a workspace-local
|
||||
host sits behind the app-level left sidebar. The workspace environment bridge preserves existing
|
||||
editing sessions, caches, file-link resolution and supported navigation context without moving
|
||||
their providers. Keep this capability at the workspace boundary; new sheet callers must not
|
||||
assemble provider internals. Wide layouts retain local hosting and pane geometry. Both hosts
|
||||
forward the same Gorhom modal queue; neither creates another stack. See
|
||||
Both kinds render the same `SheetPane` (`packages/app/src/screens/workspace/sheet-pane/`): the
|
||||
header (icon, title, Back when there is a list to return to, Open as tab, Close), the pane, its
|
||||
bottom inset, and its focus. Only the container differs, a workspace sheet in `sheet-tabs.tsx` or a
|
||||
menu page in `sheet-pane/page.tsx`, so a fix to what a sheet shows lands in one place. The
|
||||
container owns Android Back; the pane never subscribes to it.
|
||||
|
||||
Sheets are modal and cover both compact sidebars as well as the workspace. The header drags the
|
||||
sheet through `SheetDragArea`, 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. Compact sheets use the app overlay
|
||||
host: a workspace-local host sits behind the app-level left sidebar. The sheet pane bridge
|
||||
(`useSheetPaneBridge`) preserves existing editing sessions, caches, file-link resolution and
|
||||
supported navigation context without moving their providers. Keep this capability at the workspace
|
||||
boundary; new sheet callers must not assemble provider internals. Wide layouts retain local hosting
|
||||
and pane geometry. Both hosts forward the same Gorhom modal queue; neither creates another stack.
|
||||
See
|
||||
[floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site).
|
||||
|
||||
A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it.
|
||||
|
||||
@@ -410,6 +410,19 @@ bridges the menu's two contexts; the rest pass `null`.
|
||||
|
||||
Wrapping providers _around_ the modal does nothing. They land on the wrong side.
|
||||
|
||||
## Gotcha 8 — Read safe-area insets where the overlay draws
|
||||
|
||||
A compact sheet tab's body (`SheetBottomInset` in `adaptive-modal-sheet.tsx`) pads the bottom
|
||||
inset and gives its content a bottom inset of 0, so the composer dock sits the same in a sheet as
|
||||
in a tab. An overlay opened from that content and calling `useSafeAreaInsets()` at its call site
|
||||
gets that 0 and draws over the navigation bar.
|
||||
|
||||
- A Gorhom sheet reads the inset inside its children, which render in the app's sheet host. See
|
||||
`CompactSheetBody`, `MenuSheetScrollView` and `ComboboxSheetFrame`.
|
||||
- An RN `Modal` passes the call site's context straight through, so put a `SafeAreaProvider`
|
||||
inside it and read the inset below that. The modal is its own window, and the provider measures
|
||||
it. See `attachment-lightbox.tsx`.
|
||||
|
||||
## Recipe for a new anchored panel
|
||||
|
||||
Before you write a new one, ask:
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **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).
|
||||
- **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).
|
||||
- **Sheet tab** — A compact-layout sheet holding one file or subagent tab over the workspace without adding it to the tab switcher. A subagent chosen from the subagents list shows as a page of the list's sheet instead of a sheet of its own. UI action: "Open as tab". Code: `SheetTab` and `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `SheetPane` (`packages/app/src/screens/workspace/sheet-pane/`). 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).
|
||||
|
||||
+23
-4
@@ -52,10 +52,12 @@ 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
|
||||
A row whose result opens a sheet opens it as a page of the menu's own sheet, never as a second
|
||||
sheet over it: the row names a [fill page](#a-page-that-fills-the-sheet) with `opensPage`, the page
|
||||
replaces the list in place, Back returns to the list, and Close ends both. 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.
|
||||
store which sheet tab a row would show (`useSubagentSheetTab`), since the answer changes with the
|
||||
layout; a row with none opens its tab.
|
||||
|
||||
## Pages
|
||||
|
||||
@@ -101,6 +103,23 @@ The sheet keeps `keyboardBehavior="interactive"`. `extend` grows a sheet to its
|
||||
point, and with `enableDynamicSizing` that point is the content's own height — a short page does
|
||||
not grow, and the keyboard comes up over the field you are typing into.
|
||||
|
||||
## A page that fills the sheet
|
||||
|
||||
A page can carry its own content instead of rows: `{ id, fill: true, content }`. Only the sheet
|
||||
renders one. It takes the full-sheet snap point (`FULL_SHEET_SNAP_POINTS`) instead of sizing to
|
||||
content, and the sheet stops panning on content drags, because the page scrolls on its own and a
|
||||
drag would fight that scroll. The page's header drags the sheet instead, through `SheetDragArea`.
|
||||
|
||||
The content renders on the far side of the sheet's portal, so it brings its own context bridge,
|
||||
built where the page is defined. Sheet tabs are the one user: `useSheetPanePage` turns a `SheetTab`
|
||||
into a fill page around the same `SheetPane` the workspace's own sheets render. See
|
||||
[explorer-sidebar.md](explorer-sidebar.md#sheets).
|
||||
|
||||
Back pops one page. The header's Back and Android Back both call `goBack`, which answers `false` at
|
||||
the root so the sheet closes instead. A page whose definition disappears while it is open, such as
|
||||
a subagent archived from elsewhere, is pruned from the path, and the sheet falls back to the page
|
||||
that opened it.
|
||||
|
||||
## Hover intent
|
||||
|
||||
A flyout **overlaps its parent by 5pt** rather than sitting beside it. With a gap there is a strip
|
||||
@@ -189,7 +208,7 @@ its own.
|
||||
entering animation never ends at the off-screen measuring position; on a slow machine that
|
||||
write moved the open menu back off-screen.
|
||||
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
|
||||
rarely the height of the page before it.
|
||||
rarely the height of the page before it. A fill page is the one exception.
|
||||
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
|
||||
both menu contexts through the sheet's `contextBridge`. Providing them around the modal puts
|
||||
them on the wrong side of the portal and every item inside throws. Gotcha 7 in
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0-beta.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0-beta.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -37590,7 +37590,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0-beta.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -39793,12 +39793,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0-beta.2",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/server": "0.14.0-beta.1",
|
||||
"@getpaseo/client": "0.14.0-beta.2",
|
||||
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||
"@getpaseo/server": "0.14.0-beta.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -40045,10 +40045,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0-beta.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/relay": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||
"@getpaseo/relay": "0.14.0-beta.2",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -40060,7 +40060,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -40304,7 +40304,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0-beta.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -40350,7 +40350,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0-beta.2",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -40379,15 +40379,15 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0-beta.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"i18next": "^26.3.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/client": "0.14.0-beta.2",
|
||||
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -40397,8 +40397,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/client": "0.14.0-beta.2",
|
||||
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40420,7 +40420,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0-beta.2",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -40447,7 +40447,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0-beta.2",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
"@noble/curves": "^2.4.0",
|
||||
@@ -40668,16 +40668,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0-beta.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/highlight": "0.14.0-beta.1",
|
||||
"@getpaseo/plugin": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/relay": "0.14.0-beta.1",
|
||||
"@getpaseo/client": "0.14.0-beta.2",
|
||||
"@getpaseo/highlight": "0.14.0-beta.2",
|
||||
"@getpaseo/plugin": "0.14.0-beta.2",
|
||||
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||
"@getpaseo/relay": "0.14.0-beta.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -41598,7 +41598,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0-beta.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -43208,7 +43208,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0-beta.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0-beta.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -5,12 +5,16 @@ import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
expectSubagentRowGone,
|
||||
expectSubagentRowVisible,
|
||||
openSubagentFromCompactTrack,
|
||||
seedSubagent,
|
||||
} from "../support/helpers/subagents";
|
||||
import {
|
||||
backToList,
|
||||
closeSheet,
|
||||
expectNoSheetOpen,
|
||||
expectOneSheetOpen,
|
||||
expectOpenTabCount,
|
||||
openSheetAsTab,
|
||||
expectDeclinedDiscardPrompt,
|
||||
@@ -52,6 +56,10 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
|
||||
{ parentId: workspace.agentId, title: "Sheet child" },
|
||||
);
|
||||
const grandchild = await seedSubagent(
|
||||
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
|
||||
{ parentId: child.id, title: "Sheet grandchild" },
|
||||
);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
|
||||
await expectOpenTabCount(page, 1);
|
||||
|
||||
@@ -79,12 +87,14 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
await tapSheetBackdrop(page);
|
||||
});
|
||||
|
||||
await test.step("a subagent opens in a sheet over the subagents list", async () => {
|
||||
await test.step("a subagent opens in place of the subagents list", async () => {
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
|
||||
const childSheet = sheet(page, child.title);
|
||||
await expect(childSheet).toBeVisible();
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await expectSubagentRowGone(page, child.id);
|
||||
await expectOneSheetOpen(page);
|
||||
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.
|
||||
@@ -94,23 +104,51 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
await expectOpenTabCount(page, 1);
|
||||
});
|
||||
|
||||
await test.step("closing the subagent's sheet uncovers the list", async () => {
|
||||
await closeSheet(page, child.title);
|
||||
await test.step("Back returns the sheet to the list", async () => {
|
||||
await backToList(page, child.title);
|
||||
await expectSubagentRowVisible(page, child.id);
|
||||
await expectOneSheetOpen(page);
|
||||
});
|
||||
|
||||
await test.step("Close closes the list with the subagent", async () => {
|
||||
await page.getByTestId(`subagents-track-row-${child.id}`).click();
|
||||
await closeSheet(page, child.title);
|
||||
await expectSubagentRowGone(page, child.id);
|
||||
await expectNoSheetOpen(page);
|
||||
});
|
||||
|
||||
await test.step("the parent box in a subagent's sheet closes it onto the parent", async () => {
|
||||
await page.getByTestId(`subagents-track-row-${child.id}`).click();
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
const childSheet = sheet(page, child.title);
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await expect(childSheet.getByTestId("composer-parent-agent-pill")).toHaveCount(0);
|
||||
await childSheet.getByTestId("parent-agent-callout-open").click();
|
||||
await expect(childSheet).toHaveCount(0);
|
||||
await expectNoSheetOpen(page);
|
||||
await expectOpenTabCount(page, 1);
|
||||
});
|
||||
|
||||
await test.step("a nested subagent's parent box lands on its parent's sheet", async () => {
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
const childSheet = sheet(page, child.title);
|
||||
await openSubagentFromCompactTrack(page, grandchild.id, childSheet);
|
||||
const grandchildSheet = sheet(page, grandchild.title);
|
||||
await expectSheetAtSnap(page, grandchild.title);
|
||||
await expect(grandchildSheet.getByTestId("parent-agent-callout")).toContainText(child.title);
|
||||
|
||||
await grandchildSheet.getByTestId("parent-agent-callout-open").click();
|
||||
await expect(grandchildSheet).toHaveCount(0);
|
||||
await expect(childSheet).toBeVisible();
|
||||
await expectOneSheetOpen(page);
|
||||
|
||||
await childSheet.getByTestId("parent-agent-callout-open").click();
|
||||
await expect(childSheet).toHaveCount(0);
|
||||
await expectNoSheetOpen(page);
|
||||
await expectOpenTabCount(page, 1);
|
||||
});
|
||||
|
||||
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 openSubagentFromCompactTrack(page, child.id);
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await openSheetAsTab(page, child.title);
|
||||
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
|
||||
@@ -123,7 +161,7 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
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 test.step("once its tab closes, the subagent reopens in place of the list", async () => {
|
||||
await page
|
||||
.getByRole("button", { name: /Switch tabs/ })
|
||||
.filter({ visible: true })
|
||||
@@ -141,7 +179,7 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
|
||||
await openSubagentFromCompactTrack(page, child.id);
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await closeSheet(page, child.title);
|
||||
await backToList(page, child.title);
|
||||
await expectSubagentRowVisible(page, child.id);
|
||||
await tapSheetBackdrop(page);
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import path from "node:path";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
@@ -214,3 +216,66 @@ test.describe("provider settings overlay stack", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function openTimedOutProvider(page: Page, workspaceId: string, width: number) {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await page.getByRole("button", { name: /Select model/ }).click();
|
||||
if (width < 600) {
|
||||
await page.getByTestId("agent-controls-model").click();
|
||||
} else {
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
}
|
||||
await page.getByTestId("model-provider-picker-timeout").click();
|
||||
await expect(page.getByText(/Timed out refreshing Pi timeout/)).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectErrorHasSideGutters(page: Page) {
|
||||
const error = page.getByText(/Timed out refreshing Pi timeout/);
|
||||
const bounds = await error.evaluate((element) => {
|
||||
const text = element.getBoundingClientRect();
|
||||
const state = element.parentElement!.getBoundingClientRect();
|
||||
return { left: text.left - state.left, right: state.right - text.right };
|
||||
});
|
||||
expect(bounds.left, "error text stays inside the empty state's left edge").toBeGreaterThan(0);
|
||||
expect(bounds.right, "error text stays inside the empty state's right edge").toBeGreaterThan(0);
|
||||
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test.use({
|
||||
e2eDaemonConfig: {
|
||||
version: 1,
|
||||
agents: {
|
||||
catalogRefreshTimeoutMs: 2000,
|
||||
providers: {
|
||||
"picker-timeout": {
|
||||
extends: "pi",
|
||||
label: "Pi timeout",
|
||||
enabled: true,
|
||||
command: [
|
||||
process.execPath,
|
||||
path.resolve("e2e/fixtures/fake-pi-rpc.mjs"),
|
||||
"--stall-model-discovery",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
test.describe("model discovery timeout", () => {
|
||||
for (const width of [1200, 390]) {
|
||||
test(`keeps a wrapped discovery error away from both picker edges at ${width}px`, async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
const workspace = await withWorkspace({ prefix: "model-error-spacing-" });
|
||||
await openTimedOutProvider(page, workspace.workspaceId, width);
|
||||
await page.screenshot({ path: testInfo.outputPath("model-error.png") });
|
||||
await expectErrorHasSideGutters(page);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -479,3 +479,76 @@ test.describe("Settings — compact master-detail", () => {
|
||||
await expect(page.getByTestId("welcome-direct-connection")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
async function measureDockedNavigation(page: import("@playwright/test").Page, width: number) {
|
||||
await page.setViewportSize({ width, height: 951 });
|
||||
const history = page.getByRole("button", { name: /^History/ }).filter({ visible: true });
|
||||
const settings = page
|
||||
.getByRole("button", { name: "Settings", exact: true })
|
||||
.filter({ visible: true });
|
||||
await expect(history).toBeVisible();
|
||||
await expect(settings).toBeVisible();
|
||||
return {
|
||||
width,
|
||||
history: await history.evaluate((el) => el.getBoundingClientRect().height),
|
||||
settings: await settings.evaluate((el) => el.getBoundingClientRect().height),
|
||||
};
|
||||
}
|
||||
|
||||
async function measureSettingsSplit(page: import("@playwright/test").Page, width: number) {
|
||||
await page.setViewportSize({ width, height: 951 });
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
const detail = page.getByTestId("settings-detail-pane");
|
||||
await expect(sidebar).toBeVisible();
|
||||
await expect(detail).toBeVisible();
|
||||
return {
|
||||
width,
|
||||
sidebar: await sidebar.evaluate((el) => el.getBoundingClientRect().width),
|
||||
detail: await detail.evaluate((el) => el.getBoundingClientRect().width),
|
||||
};
|
||||
}
|
||||
|
||||
async function expectConsistentDockedNavigation(page: import("@playwright/test").Page) {
|
||||
const wide = await measureDockedNavigation(page, 951);
|
||||
for (const width of [600, 669, 719, 720]) {
|
||||
const narrow = await measureDockedNavigation(page, width);
|
||||
expect
|
||||
.soft(narrow.history, `History keeps its wide-layout density at ${width}`)
|
||||
.toBeCloseTo(wide.history, 0);
|
||||
expect
|
||||
.soft(narrow.settings, `Settings keeps its wide-layout density at ${width}`)
|
||||
.toBeCloseTo(wide.settings, 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectSettingsDetailKeepsReadingWidth(page: import("@playwright/test").Page) {
|
||||
const reference = await measureSettingsSplit(page, 720);
|
||||
for (const width of [600, 669, 719]) {
|
||||
const narrow = await measureSettingsSplit(page, width);
|
||||
expect
|
||||
.soft(narrow.detail, `Settings preserves its detail budget at ${width}`)
|
||||
.toBeGreaterThanOrEqual(reference.detail);
|
||||
expect
|
||||
.soft(narrow.sidebar, `Settings navigation yields width at ${width}`)
|
||||
.toBeLessThan(reference.sidebar);
|
||||
}
|
||||
const wide = await measureSettingsSplit(page, 951);
|
||||
expect(wide.sidebar).toBeCloseTo(reference.sidebar, 0);
|
||||
expect(wide.detail).toBeGreaterThan(reference.detail);
|
||||
}
|
||||
|
||||
test("docked navigation keeps its density and Settings preserves reading width", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "narrow-wide-layout-" });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await expectConsistentDockedNavigation(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "appearance");
|
||||
await expectAppearanceContent(page);
|
||||
await expectSettingsDetailKeepsReadingWidth(page);
|
||||
await page.setViewportSize({ width: 599, height: 951 });
|
||||
await expectSettingsSidebarHidden(page);
|
||||
await expectAppearanceContent(page);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { scrollChatAwayFromBottom } from "../support/helpers/agent-bottom-anchor";
|
||||
import { gateSecondToolCall } from "../support/helpers/tool-call-gate";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
readReactRenders,
|
||||
recordReactRenders,
|
||||
resetReactRenders,
|
||||
} from "../support/helpers/react-renders";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
@@ -115,23 +122,27 @@ async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string
|
||||
});
|
||||
});
|
||||
|
||||
function appendCompletedCalls(extraCompletedCalls: number) {
|
||||
if (extraCompletedCalls > 0 && (!firstCallId || !firstCompletedMessage)) {
|
||||
throw new Error("Expected the first completed tool call before release");
|
||||
}
|
||||
for (let index = 0; index < extraCompletedCalls; index += 1) {
|
||||
sendToPage?.(
|
||||
cloneToolCallWithId(
|
||||
firstCompletedMessage as WebSocketMessage,
|
||||
`${firstCallId}:${sequenceOffset + index}`,
|
||||
sequenceOffset + index + 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
sequenceOffset += extraCompletedCalls;
|
||||
}
|
||||
return {
|
||||
waitForFirstCompleted: () => firstCompleted,
|
||||
appendCompletedCalls,
|
||||
release(extraCompletedCalls = 0) {
|
||||
isHolding = false;
|
||||
if (extraCompletedCalls > 0 && (!firstCallId || !firstCompletedMessage)) {
|
||||
throw new Error("Expected the first completed tool call before release");
|
||||
}
|
||||
for (let index = 0; index < extraCompletedCalls; index += 1) {
|
||||
sendToPage?.(
|
||||
cloneToolCallWithId(
|
||||
firstCompletedMessage as WebSocketMessage,
|
||||
`${firstCallId}:${index}`,
|
||||
index + 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
sequenceOffset = extraCompletedCalls;
|
||||
appendCompletedCalls(extraCompletedCalls);
|
||||
for (const message of heldMessages.splice(0)) {
|
||||
sendToPage?.(shiftTimelineSequence(message, sequenceOffset));
|
||||
}
|
||||
@@ -139,17 +150,24 @@ async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string
|
||||
};
|
||||
}
|
||||
|
||||
async function configureOverviewToolCalls(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
type OverviewChatDisplay = "collapseToolCalls" | "collapseTurns";
|
||||
|
||||
// Expand thinking keeps reasoning out of tool call runs, so the first tool call starts alone.
|
||||
async function configureOverviewToolCalls(page: Page, chatDisplay: OverviewChatDisplay) {
|
||||
await page.addInitScript((display) => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
|
||||
JSON.stringify({ chatDisplay: display, autoExpandReasoning: true }),
|
||||
);
|
||||
});
|
||||
}, chatDisplay);
|
||||
}
|
||||
|
||||
async function createOverviewAgent(page: Page, title: string) {
|
||||
await configureOverviewToolCalls(page);
|
||||
async function createOverviewAgent(
|
||||
page: Page,
|
||||
title: string,
|
||||
chatDisplay: OverviewChatDisplay = "collapseToolCalls",
|
||||
) {
|
||||
await configureOverviewToolCalls(page, chatDisplay);
|
||||
return seedMockAgentWorkspace({
|
||||
repoPrefix: "tool-call-overview-sheet-",
|
||||
title,
|
||||
@@ -204,6 +222,36 @@ function readScrollMetrics(root: HTMLElement) {
|
||||
};
|
||||
}
|
||||
|
||||
const FIRST_CALL_DETAILS = "export function ConversationList()";
|
||||
|
||||
function firstToolCall(page: Page): Locator {
|
||||
return page.getByTestId("tool-call-badge").filter({ hasText: "conversation-list.tsx" }).first();
|
||||
}
|
||||
|
||||
async function openToolDetails(badge: Locator): Promise<void> {
|
||||
await badge.getByRole("button").first().click();
|
||||
}
|
||||
|
||||
async function timelineRowOf(locator: Locator) {
|
||||
const row = await locator.locator("xpath=ancestor::*[@data-history-row-id][1]").elementHandle();
|
||||
if (!row) throw new Error("Expected the locator inside a timeline row");
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Whether the locator renders inside this exact row node, i.e. the row was not remounted. */
|
||||
async function isInsideRow(
|
||||
locator: Locator,
|
||||
row: Awaited<ReturnType<typeof timelineRowOf>>,
|
||||
): Promise<boolean> {
|
||||
return locator.evaluate((node, previousRow) => previousRow.contains(node), row);
|
||||
}
|
||||
|
||||
async function topOf(row: Locator): Promise<number> {
|
||||
const box = await row.boundingBox();
|
||||
if (!box) throw new Error("Expected a visible timeline row");
|
||||
return box.y;
|
||||
}
|
||||
|
||||
test.describe("compact overview tool calls", () => {
|
||||
test.use({ viewport: COMPACT_VIEWPORT, hasTouch: true });
|
||||
|
||||
@@ -219,8 +267,16 @@ test.describe("compact overview tool calls", () => {
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Exercise the overview tool-call sheet.");
|
||||
await gate.waitForFirstCompleted();
|
||||
|
||||
const single = firstToolCall(page);
|
||||
await expect(single).toBeVisible();
|
||||
await expect(page.getByTestId("tool-call-group")).toHaveCount(0);
|
||||
await openToolDetails(single);
|
||||
await expect(page.getByTestId("tool-call-sheet-close")).toBeVisible();
|
||||
await page.getByTestId("tool-call-sheet-close").click();
|
||||
gate.appendCompletedCalls(1);
|
||||
const group = page.getByTestId("tool-call-group").first();
|
||||
await expect(group).toBeVisible();
|
||||
await expect(page.getByTestId("tool-call-group-sheet")).toBeHidden();
|
||||
await group.click();
|
||||
|
||||
const sheet = page.getByTestId("tool-call-group-sheet");
|
||||
@@ -261,27 +317,86 @@ test.describe("compact overview tool calls", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps overview tool calls inline on desktop", async ({ page }) => {
|
||||
for (const display of ["collapseToolCalls", "collapseTurns"] as const) {
|
||||
test(`keeps an opened lone tool call open when it becomes a group (${display})`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await createOverviewAgent(page, "Desktop overview tool calls", display);
|
||||
await recordReactRenders(page);
|
||||
try {
|
||||
const gate = await gateSecondToolCall(page, agent.agentId);
|
||||
await openOverviewAgent(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls.");
|
||||
await gate.waitForFirstCompleted();
|
||||
const lone = firstToolCall(page);
|
||||
const group = page.getByTestId("tool-call-group").first();
|
||||
const details = page.getByText(FIRST_CALL_DETAILS);
|
||||
|
||||
await test.step("a lone tool call shows as itself", async () => {
|
||||
await expect(lone).toBeVisible();
|
||||
await expect(group).toHaveCount(0);
|
||||
});
|
||||
|
||||
await gate.waitForSecondRunning();
|
||||
await test.step("opening it re-renders that tool call and nothing else", async () => {
|
||||
await resetReactRenders(page);
|
||||
await openToolDetails(lone);
|
||||
await expect(details).toBeVisible();
|
||||
const renders = await readReactRenders(page);
|
||||
expect(renders.AgentStreamView ?? 0).toBe(0);
|
||||
expect(renders.StreamRowContent ?? 0).toBe(0);
|
||||
expect(renders.ToolCall).toBe(1);
|
||||
});
|
||||
|
||||
await test.step("the second call forms an open group around the opened call", async () => {
|
||||
// Reading above the tail, the row holds still; at the tail the new rows push it up.
|
||||
await scrollChatAwayFromBottom(page, { deltaY: -160, minDistanceFromBottom: 72 });
|
||||
const loneTop = await topOf(lone);
|
||||
const loneRow = await timelineRowOf(lone);
|
||||
await resetReactRenders(page);
|
||||
gate.releaseSecondRunning();
|
||||
await expect(group.getByTestId("tool-call-badge")).toHaveCount(2);
|
||||
await expect(group.getByText(FIRST_CALL_DETAILS)).toBeVisible();
|
||||
expect(await isInsideRow(group, loneRow)).toBe(true);
|
||||
expect(Math.abs((await topOf(group)) - loneTop)).toBeLessThanOrEqual(2);
|
||||
expect((await readReactRenders(page)).AgentStreamView).toBeGreaterThan(0);
|
||||
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("closing and reopening the group keeps the call open", async () => {
|
||||
await openToolDetails(group);
|
||||
await expect(group.getByTestId("tool-call-badge")).toHaveCount(0);
|
||||
await expect(details).toHaveCount(0);
|
||||
await openToolDetails(group);
|
||||
await expect(group.getByTestId("tool-call-badge")).toHaveCount(2);
|
||||
await expect(group.getByText(FIRST_CALL_DETAILS)).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("forms a closed group when the lone tool call was left closed", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await createOverviewAgent(page, "Desktop overview tool calls");
|
||||
|
||||
const agent = await createOverviewAgent(page, "Closed desktop overview tool calls");
|
||||
try {
|
||||
const gate = await holdStreamAfterFirstCompletedToolCall(page, agent.agentId);
|
||||
const gate = await gateSecondToolCall(page, agent.agentId);
|
||||
await openOverviewAgent(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls.");
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Leave the first tool call closed.");
|
||||
await gate.waitForFirstCompleted();
|
||||
const lone = firstToolCall(page);
|
||||
const group = page.getByTestId("tool-call-group").first();
|
||||
await expect(lone).toBeVisible();
|
||||
await expect(group).toHaveCount(0);
|
||||
|
||||
await gate.waitForSecondRunning();
|
||||
const loneTop = await topOf(lone);
|
||||
gate.releaseSecondRunning();
|
||||
await expect(group).toBeVisible();
|
||||
|
||||
// Keep streaming layout changes out of the pointer-down/pointer-up interval.
|
||||
const header = group.getByRole("button").first();
|
||||
await header.click();
|
||||
await expect(group.getByTestId("tool-call-badge").first()).toBeVisible();
|
||||
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
|
||||
|
||||
gate.release(1);
|
||||
await expect.poll(() => group.getByTestId("tool-call-badge").count()).toBeGreaterThanOrEqual(2);
|
||||
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
|
||||
await expect(group.getByTestId("tool-call-badge")).toHaveCount(0);
|
||||
expect(Math.abs((await topOf(group)) - loneTop)).toBeLessThanOrEqual(2);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { gateSecondToolCall } from "../support/helpers/tool-call-gate";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
interface ShimmerEvidence {
|
||||
animationDuration: string;
|
||||
endPx: number;
|
||||
@@ -14,127 +12,6 @@ interface ShimmerEvidence {
|
||||
startPx: number;
|
||||
}
|
||||
|
||||
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
|
||||
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||
? (envelope.message as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getToolCallStatus(
|
||||
message: WebSocketMessage,
|
||||
agentId: string,
|
||||
): { callId: string; status: string } | null {
|
||||
const sessionMessage = parseSessionMessage(message);
|
||||
if (sessionMessage?.type !== "agent_stream") {
|
||||
return null;
|
||||
}
|
||||
const payload = sessionMessage.payload as Record<string, unknown> | undefined;
|
||||
if (payload?.agentId !== agentId) {
|
||||
return null;
|
||||
}
|
||||
const event = payload.event as Record<string, unknown> | undefined;
|
||||
const item = event?.item as Record<string, unknown> | undefined;
|
||||
return event?.type === "timeline" &&
|
||||
item?.type === "tool_call" &&
|
||||
typeof item.callId === "string" &&
|
||||
typeof item.status === "string"
|
||||
? { callId: item.callId, status: item.status }
|
||||
: null;
|
||||
}
|
||||
|
||||
function replaceToolCallStatus(message: WebSocketMessage, status: string): string {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
const envelope = JSON.parse(raw) as {
|
||||
message?: { payload?: { event?: { item?: { status?: string } } } };
|
||||
};
|
||||
const item = envelope.message?.payload?.event?.item;
|
||||
if (!item) {
|
||||
throw new Error("Expected a tool-call session message");
|
||||
}
|
||||
item.status = status;
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
async function gateSecondToolCall(page: Page, agentId: string) {
|
||||
let firstCallId: string | null = null;
|
||||
let secondCallId: string | null = null;
|
||||
let secondRunningMessage: WebSocketMessage | null = null;
|
||||
let pauseAfterSecondRunning = false;
|
||||
let releaseSecondRequested = false;
|
||||
let forwardSecondRunning: (() => void) | null = null;
|
||||
let resolveFirstCompleted!: () => void;
|
||||
let resolveSecondRunning!: () => void;
|
||||
const firstCompleted = new Promise<void>((resolve) => {
|
||||
resolveFirstCompleted = resolve;
|
||||
});
|
||||
const secondRunning = new Promise<void>((resolve) => {
|
||||
resolveSecondRunning = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
forwardSecondRunning = () => {
|
||||
if (secondRunningMessage) {
|
||||
ws.send(secondRunningMessage);
|
||||
secondRunningMessage = null;
|
||||
}
|
||||
};
|
||||
ws.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
if (pauseAfterSecondRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolCall = getToolCallStatus(message, agentId);
|
||||
if (!toolCall) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!firstCallId) {
|
||||
firstCallId = toolCall.callId;
|
||||
}
|
||||
if (toolCall.callId === firstCallId) {
|
||||
ws.send(message);
|
||||
if (toolCall.status === "completed") {
|
||||
resolveFirstCompleted();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
secondCallId ??= toolCall.callId;
|
||||
if (toolCall.callId === secondCallId) {
|
||||
const transformedMessage =
|
||||
toolCall.status === "completed" ? replaceToolCallStatus(message, "running") : message;
|
||||
secondRunningMessage = transformedMessage;
|
||||
pauseAfterSecondRunning = true;
|
||||
resolveSecondRunning();
|
||||
if (releaseSecondRequested) {
|
||||
forwardSecondRunning?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
waitForFirstCompleted: () => firstCompleted,
|
||||
waitForSecondRunning: () => secondRunning,
|
||||
releaseSecondRunning() {
|
||||
releaseSecondRequested = true;
|
||||
forwardSecondRunning?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readShimmerEvidence(locator: Locator): Promise<ShimmerEvidence> {
|
||||
return locator.evaluate((root) => {
|
||||
const shimmer = Array.from(root.querySelectorAll<HTMLElement>("*")).find((element) =>
|
||||
|
||||
@@ -203,6 +203,47 @@ test("received messages show their sender and open that agent", async ({ page })
|
||||
}
|
||||
});
|
||||
|
||||
test("long sender attribution stays on one line in compact chat", async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.addInitScript(() =>
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "dark" })),
|
||||
);
|
||||
const recipient = await seedMockAgentWorkspace({
|
||||
repoPrefix: "compact-attribution-",
|
||||
title: "Implementation",
|
||||
});
|
||||
try {
|
||||
const title = "Reviewer checking the compact chat timeline and sender attribution";
|
||||
const sender = await recipient.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: recipient.cwd,
|
||||
workspaceId: recipient.workspaceId,
|
||||
title,
|
||||
model: "gpt-6.1-sol",
|
||||
modeId: "full-access",
|
||||
});
|
||||
await recipient.client.sendAgentMessage(
|
||||
recipient.agentId,
|
||||
"The sender label stays on one line, even when the agent has a long name.",
|
||||
{ sourceAgentId: sender.id },
|
||||
);
|
||||
await openAgentRoute(page, recipient);
|
||||
await expectComposerVisible(page);
|
||||
const attribution = page.getByRole("button", { name: `Sent by ${title}`, exact: true });
|
||||
await expect(attribution).toBeVisible();
|
||||
await expectSenderTruncatedWithinMessage(attribution, title);
|
||||
await expectSenderAlignedWithBubble(attribution);
|
||||
await attribution.scrollIntoViewIfNeeded();
|
||||
await page.screenshot({ path: testInfo.outputPath("sender-compact-390.png") });
|
||||
await page.setViewportSize({ width: 320, height: 740 });
|
||||
await expectSenderTruncatedWithinMessage(attribution, title);
|
||||
await expectSenderAlignedWithBubble(attribution);
|
||||
await page.screenshot({ path: testInfo.outputPath("sender-compact-320.png") });
|
||||
} finally {
|
||||
await recipient.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
interface MessagePreviewWatch {
|
||||
/** Frames after the watch started on which the fade or "Show more" control was missing. */
|
||||
framesWithoutClip: number[];
|
||||
@@ -316,6 +357,20 @@ test("opening a turn keeps the received message above it clipped on every frame"
|
||||
}
|
||||
});
|
||||
|
||||
async function expectSenderTruncatedWithinMessage(attribution: Locator, title: string) {
|
||||
const label = attribution.getByText(`Sent by ${title}`, { exact: true });
|
||||
await expect(label).toHaveCSS("white-space", "nowrap");
|
||||
await expect(label).toHaveCSS("text-overflow", "ellipsis");
|
||||
const bounds = await label.evaluate((element) => ({
|
||||
textWidth: element.scrollWidth,
|
||||
visibleWidth: element.clientWidth,
|
||||
right: element.getBoundingClientRect().right,
|
||||
viewportWidth: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(bounds.textWidth).toBeGreaterThan(bounds.visibleWidth);
|
||||
expect(bounds.right).toBeLessThanOrEqual(bounds.viewportWidth);
|
||||
}
|
||||
|
||||
async function expectSenderAlignedWithBubble(attribution: Locator) {
|
||||
const edges = await attribution.evaluate((button) => {
|
||||
const bubble = button.nextElementSibling;
|
||||
@@ -475,7 +530,20 @@ async function expectThinkingPresentation(page: Page, autoExpandReasoning: boole
|
||||
await expect(group).toContainText(/read 1 file/i);
|
||||
await expect(group.getByRole("button").first()).not.toContainText(/thought/i);
|
||||
await group.getByRole("button").first().click();
|
||||
await expect(reasoning).toBeVisible();
|
||||
// Expand thinking is off: the group opens its rows, but the thought row stays closed
|
||||
// until the reader opens it.
|
||||
const thought = group
|
||||
.getByTestId("tool-call-badge")
|
||||
.filter({ hasText: /^Thinking/ })
|
||||
.first();
|
||||
await expect(thought).toBeVisible();
|
||||
await expect(reasoning).toHaveCount(0);
|
||||
// A press right after the group opens occasionally does not register; retry until the
|
||||
// thought row opens.
|
||||
await expect(async () => {
|
||||
await thought.getByRole("button").first().click();
|
||||
await expect(reasoning).toBeVisible({ timeout: 2_000 });
|
||||
}).toPass();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ for await (const line of lines) {
|
||||
const command = JSON.parse(line);
|
||||
let data = {};
|
||||
if (command.type === "get_available_models") {
|
||||
if (process.argv.includes("--stall-model-discovery")) continue;
|
||||
data = {
|
||||
models: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Native user message preview
|
||||
|
||||
Open a chat with a user message containing 16 newline-separated lines of
|
||||
`Preview line N`, with N from 1 to 16. Scroll the message into view on a
|
||||
phone-sized Android emulator or iOS simulator. Its text must exceed the 240-point preview cap,
|
||||
while its expanded bubble and toggle fit in the chat viewport. Close any other
|
||||
expanded messages so the toggle labels identify this message.
|
||||
|
||||
Replay `native-user-message-preview.android.ad` or
|
||||
`native-user-message-preview.ios.ad` through the existing mobile runner or
|
||||
agent-device. It checks that a clipped message offers Show more and
|
||||
that two expand/collapse round trips keep both controls reachable. Before the
|
||||
fix, the first Show more assertion fails because native layout reports the
|
||||
capped height as the full content height.
|
||||
@@ -0,0 +1,14 @@
|
||||
context platform=android timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "role=\"button\" label=\"Show more\"" 10000
|
||||
press "role=\"button\" label=\"Show more\""
|
||||
wait "role=\"button\" label=\"Show less\"" 10000
|
||||
press "role=\"button\" label=\"Show less\""
|
||||
wait "role=\"button\" label=\"Show more\"" 10000
|
||||
press "role=\"button\" label=\"Show more\""
|
||||
wait "role=\"button\" label=\"Show less\"" 10000
|
||||
press "role=\"button\" label=\"Show less\""
|
||||
wait "role=\"button\" label=\"Show more\"" 10000
|
||||
close
|
||||
@@ -0,0 +1,14 @@
|
||||
context platform=ios timeout=60000 retries=0
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "role=\"button\" label=\"Show more\"" 10000
|
||||
press "role=\"button\" label=\"Show more\""
|
||||
wait "role=\"button\" label=\"Show less\"" 10000
|
||||
press "role=\"button\" label=\"Show less\""
|
||||
wait "role=\"button\" label=\"Show more\"" 10000
|
||||
press "role=\"button\" label=\"Show more\""
|
||||
wait "role=\"button\" label=\"Show less\"" 10000
|
||||
press "role=\"button\" label=\"Show less\""
|
||||
wait "role=\"button\" label=\"Show more\"" 10000
|
||||
close
|
||||
@@ -12,6 +12,22 @@ point and Metro workflow in [status-ring](../status-ring/README.md), importing
|
||||
`MarkdownFormattingRegression` from `./e2e/mobile/markdown-formatting/native`.
|
||||
Open the same Metro URL in a browser to compare web. Restore `index.ts` afterward.
|
||||
|
||||
With this fixture open in an Android Agent Device session, run:
|
||||
|
||||
```sh
|
||||
node packages/app/e2e/mobile/markdown-formatting/android.mjs <session>
|
||||
```
|
||||
|
||||
The runner switches to paragraphs with long inline code at a fixed 300 dp width.
|
||||
It compares the native Markdown host’s bottom with its last TextView’s bottom,
|
||||
then repeats in compact mode and after restoring regular text. More than one
|
||||
pixel of surplus fails. Keep the whole fixture visible: clipped accessibility
|
||||
bounds cannot establish measurement parity.
|
||||
|
||||
`scripts/postinstall-patches.test.mjs` regenerates both platforms’ props and
|
||||
compares them with the shipped headers. This catches missing C++ fields in the
|
||||
measurement path, which the Kotlin tests cannot exercise.
|
||||
|
||||
The package patch contains `InlineCodeFormattingTest`, which exercises actual
|
||||
Android spans in Robolectric native graphics mode, covering draw and measurement,
|
||||
span order, code inside paragraph/heading sizes, bold/italic composition, and
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const session = process.argv[2];
|
||||
assert(session, "Pass an Android agent-device session serving native.tsx");
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", ["--session", session, ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
function assertMeasurementParity(mode) {
|
||||
const snapshot = JSON.parse(ad("snapshot", "--raw", "--json"));
|
||||
assert.equal(snapshot.success, true);
|
||||
const nodes = snapshot.data.nodes;
|
||||
let last = nodes.find((node) => node.label?.endsWith("End of measurement fixture."));
|
||||
assert(last?.rect, "The last paragraph must be visible, including its bottom");
|
||||
// EnrichedMarkdownText exposes paragraph accessibility nodes inside its TextView;
|
||||
// EnrichedMarkdown exposes the TextViews themselves. Measure the native child.
|
||||
let host = nodes.find((node) => node.index === last.parentIndex);
|
||||
if (host?.type === "android.widget.TextView") {
|
||||
last = host;
|
||||
host = nodes.find((node) => node.index === last.parentIndex);
|
||||
}
|
||||
assert.equal(host?.type, "android.widget.FrameLayout");
|
||||
const viewport = nodes.find((node) => node.type === "android.widget.ScrollView");
|
||||
const footer = nodes.find((node) => node.label === "End of Markdown host");
|
||||
assert(viewport?.rect && footer?.rect, "The fixture and footer must be visible");
|
||||
assert(
|
||||
host.rect.y >= viewport.rect.y &&
|
||||
footer.rect.y + footer.rect.height <= viewport.rect.y + viewport.rect.height,
|
||||
"The whole fixture must fit in the viewport; clipped bounds cannot prove parity",
|
||||
);
|
||||
const hostBottom = host.rect.y + host.rect.height;
|
||||
const childBottom = last.rect.y + last.rect.height;
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
mode,
|
||||
host: host.rect,
|
||||
lastChild: last.rect,
|
||||
surplus: hostBottom - childBottom,
|
||||
}),
|
||||
);
|
||||
assert(
|
||||
Math.abs(hostBottom - childBottom) <= 1,
|
||||
`Markdown reserves blank space in ${mode}: host bottom ${hostBottom}, last child bottom ${childBottom}`,
|
||||
);
|
||||
}
|
||||
|
||||
ad("press", 'label="Show wrapping fixture"');
|
||||
ad("wait", "text", "End of Markdown host", "10000");
|
||||
assertMeasurementParity("regular");
|
||||
ad("press", 'label="Use compact text"');
|
||||
ad("wait", "text", "Use regular text", "10000");
|
||||
assertMeasurementParity("compact");
|
||||
ad("press", 'label="Use regular text"');
|
||||
ad("wait", "text", "Use compact text", "10000");
|
||||
assertMeasurementParity("regular restored");
|
||||
console.log("PASS Android Markdown host measurement matches its last child");
|
||||
@@ -1,6 +1,6 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Button, ScrollView, Text } from "react-native";
|
||||
import { Button, ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
@@ -10,6 +10,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
scroll: { backgroundColor: theme.colors.surface0 },
|
||||
content: { padding: 32, paddingTop: 60 },
|
||||
title: { color: theme.colors.foreground },
|
||||
wrapping: { width: 300 },
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
@@ -32,7 +33,15 @@ const source = [
|
||||
"Normal **bold** and *italic* prose.",
|
||||
].join("\n\n");
|
||||
|
||||
const wrappingSource = [
|
||||
"Measurement parity. Take a repo with a gitignored `.env` in the main checkout and a `.worktreeinclude` containing `.env`.",
|
||||
"Every creation path goes through `createPaseoWorktreeWorkflow` in `worktree-session.ts:341`, before `createAgent` in `creation/index.ts:160-174` starts.",
|
||||
"The built-in plugin lives in `plugins/worktree-include/index.server.ts` and copies `paseo.json` beside `.worktreeinclude`. End of measurement fixture.",
|
||||
].join("\n\n");
|
||||
|
||||
export default function MarkdownFormattingRegression() {
|
||||
const [wrapping, setWrapping] = useState(false);
|
||||
const toggleWrapping = useCallback(() => setWrapping((value) => !value), []);
|
||||
const [compact, setCompact] = useState(false);
|
||||
const toggleCompact = useCallback(() => setCompact((value) => !value), []);
|
||||
return (
|
||||
@@ -44,7 +53,14 @@ export default function MarkdownFormattingRegression() {
|
||||
title={compact ? "Use regular text" : "Use compact text"}
|
||||
onPress={toggleCompact}
|
||||
/>
|
||||
<MarkdownRenderer text={source} compact={compact} />
|
||||
<Button
|
||||
title={wrapping ? "Show formatting fixture" : "Show wrapping fixture"}
|
||||
onPress={toggleWrapping}
|
||||
/>
|
||||
<View style={wrapping ? styles.wrapping : undefined}>
|
||||
<MarkdownRenderer text={wrapping ? wrappingSource : source} compact={compact} />
|
||||
</View>
|
||||
<Text style={styles.title}>End of Markdown host</Text>
|
||||
</ScrollView>
|
||||
</GestureHandlerRootView>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
declare global {
|
||||
var __PASEO_E2E_REACT_RENDERS__: Record<string, number> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts which components render in each React commit, by name, the way React DevTools
|
||||
* highlights updates: a fiber rendered when React performed work on it in that commit.
|
||||
* Install before the app loads; React registers with the DevTools hook at startup.
|
||||
*/
|
||||
export async function recordReactRenders(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const PERFORMED_WORK = 1;
|
||||
interface Fiber {
|
||||
type: unknown;
|
||||
flags: number;
|
||||
child: Fiber | null;
|
||||
sibling: Fiber | null;
|
||||
alternate: Fiber | null;
|
||||
}
|
||||
const nameOf = (type: unknown): string | null => {
|
||||
if (typeof type === "function") {
|
||||
return (type as { displayName?: string }).displayName || type.name || null;
|
||||
}
|
||||
if (!type || typeof type !== "object") return null;
|
||||
const component = type as { displayName?: string; render?: unknown; type?: unknown };
|
||||
return component.displayName || nameOf(component.render) || nameOf(component.type);
|
||||
};
|
||||
const record = (root: Fiber) => {
|
||||
const renders = (globalThis.__PASEO_E2E_REACT_RENDERS__ ??= {});
|
||||
const pending: Fiber[] = [root];
|
||||
while (pending.length > 0) {
|
||||
const fiber = pending.pop()!;
|
||||
const name = typeof fiber.type === "string" ? null : nameOf(fiber.type);
|
||||
if (name && (fiber.flags & PERFORMED_WORK) !== 0) renders[name] = (renders[name] ?? 0) + 1;
|
||||
// A subtree React bailed out of is shared with the previous commit; skip it.
|
||||
if (fiber.alternate && fiber.child === fiber.alternate.child) continue;
|
||||
for (let child = fiber.child; child; child = child.sibling) pending.push(child);
|
||||
}
|
||||
};
|
||||
let rendererCount = 0;
|
||||
Object.defineProperty(globalThis, "__REACT_DEVTOOLS_GLOBAL_HOOK__", {
|
||||
configurable: true,
|
||||
value: {
|
||||
supportsFiber: true,
|
||||
isDisabled: false,
|
||||
renderers: new Map(),
|
||||
inject(renderer: unknown) {
|
||||
rendererCount += 1;
|
||||
this.renderers.set(rendererCount, renderer);
|
||||
return rendererCount;
|
||||
},
|
||||
checkDCE() {},
|
||||
onScheduleFiberRoot() {},
|
||||
onCommitFiberRoot(_rendererId: number, root: { current: Fiber }) {
|
||||
record(root.current);
|
||||
},
|
||||
onPostCommitFiberRoot() {},
|
||||
onCommitFiberUnmount() {},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetReactRenders(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
globalThis.__PASEO_E2E_REACT_RENDERS__ = {};
|
||||
});
|
||||
}
|
||||
|
||||
/** Render counts per component name since the last reset, read after the next painted frame. */
|
||||
export async function readReactRenders(page: Page): Promise<Record<string, number>> {
|
||||
return page.evaluate(async () => {
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
return { ...globalThis.__PASEO_E2E_REACT_RENDERS__ };
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export function sheet(page: Page, label: string): Locator {
|
||||
return page.getByRole("dialog", { name: label });
|
||||
}
|
||||
|
||||
/** A sheet tab's header, which carries its title, Close, and the Open as tab glyph. */
|
||||
/** A sheet tab's header, which carries its title, Back, Close, and the Open as tab glyph. */
|
||||
export function sheetHeader(page: Page, label: string): Locator {
|
||||
return page.getByTestId("sheet-tab-header").filter({ hasText: label });
|
||||
}
|
||||
@@ -48,6 +48,12 @@ export async function closeSheet(page: Page, label: string): Promise<void> {
|
||||
await expect(sheet(page, label)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Steps a sheet opened from a list back to that list, in the same sheet. */
|
||||
export async function backToList(page: Page, label: string): Promise<void> {
|
||||
await sheetHeader(page, label).getByRole("button", { name: "Back" }).click();
|
||||
await expect(sheet(page, label)).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);
|
||||
@@ -76,6 +82,15 @@ export async function selectTabFromSwitcher(page: Page, label: string): Promise<
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Each open sheet renders its own backdrop, so counting them counts the sheets. */
|
||||
export async function expectOneSheetOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
|
||||
}
|
||||
|
||||
export async function expectNoSheetOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */
|
||||
export async function tapSheetBackdrop(page: Page): Promise<void> {
|
||||
await page
|
||||
|
||||
@@ -3,7 +3,7 @@ import { startIsolatedHostDaemon } from "./isolated-host-daemon";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
import type { SeededWorkspace } from "./seed-client";
|
||||
|
||||
@@ -151,10 +151,21 @@ export async function openSubagentsTrack(page: Page): Promise<void> {
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
/** On compact the track opens as a sheet, which closes once a row is chosen. */
|
||||
export async function openSubagentFromCompactTrack(page: Page, childId: string): Promise<void> {
|
||||
await page.getByTestId("subagents-track-header").click();
|
||||
await page.getByTestId(`subagents-track-row-${childId}`).click();
|
||||
/**
|
||||
* On compact the track opens as a sheet, and the chosen row opens in its place. `track` is the
|
||||
* pane whose track to open, for when a subagent's sheet shows a track of its own.
|
||||
*/
|
||||
export async function openSubagentFromCompactTrack(
|
||||
page: Page,
|
||||
childId: string,
|
||||
track: Page | Locator = page,
|
||||
): Promise<void> {
|
||||
await track.getByTestId("subagents-track-header").click();
|
||||
const row = page.getByTestId(`subagents-track-row-${childId}`);
|
||||
// Tap the row once the sheet has brought it on screen, as a finger would. Clicking it while it is
|
||||
// still below the fold makes Playwright scroll the sheet host, and every sheet then docks short.
|
||||
await expect(row).toBeInViewport();
|
||||
await row.click();
|
||||
}
|
||||
|
||||
export async function expectSubagentRowVisible(page: Page, childId: string): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
|
||||
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||
? (envelope.message as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getToolCallStatus(
|
||||
message: WebSocketMessage,
|
||||
agentId: string,
|
||||
): { callId: string; status: string } | null {
|
||||
const sessionMessage = parseSessionMessage(message);
|
||||
if (sessionMessage?.type !== "agent_stream") {
|
||||
return null;
|
||||
}
|
||||
const payload = sessionMessage.payload as Record<string, unknown> | undefined;
|
||||
if (payload?.agentId !== agentId) {
|
||||
return null;
|
||||
}
|
||||
const event = payload.event as Record<string, unknown> | undefined;
|
||||
const item = event?.item as Record<string, unknown> | undefined;
|
||||
return event?.type === "timeline" &&
|
||||
item?.type === "tool_call" &&
|
||||
typeof item.callId === "string" &&
|
||||
typeof item.status === "string"
|
||||
? { callId: item.callId, status: item.status }
|
||||
: null;
|
||||
}
|
||||
|
||||
function replaceToolCallStatus(message: WebSocketMessage, status: string): string {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
const envelope = JSON.parse(raw) as {
|
||||
message?: { payload?: { event?: { item?: { status?: string } } } };
|
||||
};
|
||||
const item = envelope.message?.payload?.event?.item;
|
||||
if (!item) {
|
||||
throw new Error("Expected a tool-call session message");
|
||||
}
|
||||
item.status = status;
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
export async function gateSecondToolCall(page: Page, agentId: string) {
|
||||
let firstCallId: string | null = null;
|
||||
let secondCallId: string | null = null;
|
||||
let secondRunningMessage: WebSocketMessage | null = null;
|
||||
let pauseAfterSecondRunning = false;
|
||||
let releaseSecondRequested = false;
|
||||
let forwardSecondRunning: (() => void) | null = null;
|
||||
let resolveFirstCompleted!: () => void;
|
||||
let resolveSecondRunning!: () => void;
|
||||
const firstCompleted = new Promise<void>((resolve) => {
|
||||
resolveFirstCompleted = resolve;
|
||||
});
|
||||
const secondRunning = new Promise<void>((resolve) => {
|
||||
resolveSecondRunning = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
forwardSecondRunning = () => {
|
||||
if (secondRunningMessage) {
|
||||
ws.send(secondRunningMessage);
|
||||
secondRunningMessage = null;
|
||||
}
|
||||
};
|
||||
ws.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
if (pauseAfterSecondRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolCall = getToolCallStatus(message, agentId);
|
||||
if (!toolCall) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!firstCallId) {
|
||||
firstCallId = toolCall.callId;
|
||||
}
|
||||
if (toolCall.callId === firstCallId) {
|
||||
ws.send(message);
|
||||
if (toolCall.status === "completed") {
|
||||
resolveFirstCompleted();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
secondCallId ??= toolCall.callId;
|
||||
if (toolCall.callId === secondCallId) {
|
||||
const transformedMessage =
|
||||
toolCall.status === "completed" ? replaceToolCallStatus(message, "running") : message;
|
||||
secondRunningMessage = transformedMessage;
|
||||
pauseAfterSecondRunning = true;
|
||||
resolveSecondRunning();
|
||||
if (releaseSecondRequested) {
|
||||
forwardSecondRunning?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
waitForFirstCompleted: () => firstCompleted,
|
||||
waitForSecondRunning: () => secondRunning,
|
||||
releaseSecondRunning() {
|
||||
releaseSecondRequested = true;
|
||||
forwardSecondRunning?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.14.0-beta.1",
|
||||
"version": "0.14.0-beta.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -702,6 +702,28 @@ it("does not reopen the previous turn while a new run waits for its first row",
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
|
||||
});
|
||||
|
||||
// A sent prompt counts as activity before the daemon opens its turn, so the only known turn is
|
||||
// the completed one; it must stay folded until the new turn's rows arrive.
|
||||
it("keeps the previous turn folded while a sent prompt waits for its turn to open", () => {
|
||||
const timestamp = new Date();
|
||||
const tail: StreamItem[] = [
|
||||
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
|
||||
toolCall("one", "Bash"),
|
||||
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
|
||||
];
|
||||
for (const item of tail) item.turnId = "completed";
|
||||
const result = createStreamPresentation()({
|
||||
...presentationOptions,
|
||||
transform: undefined,
|
||||
tail,
|
||||
head: [],
|
||||
display: "collapseTurns",
|
||||
isTurnActive: true,
|
||||
activeTurnId: null,
|
||||
});
|
||||
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
|
||||
});
|
||||
|
||||
it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => {
|
||||
const present = createStreamPresentation();
|
||||
const timestamp = new Date();
|
||||
|
||||
@@ -113,14 +113,16 @@ export function createStreamPresentation() {
|
||||
}
|
||||
// Live text updates reach the head only, so the folded history is reused until the
|
||||
// retained rows, the latest turn's liveness, or the reader's expanded turns change.
|
||||
const isLatestTurnOpen =
|
||||
(input.isTurnActive || projection.head.length > 0) &&
|
||||
!isTurnBoundary(projection.tail.at(-1) ?? null, projection.head[0] ?? null) &&
|
||||
!(
|
||||
input.activeTurnId &&
|
||||
projection.tail.at(-1)?.turnId &&
|
||||
input.activeTurnId !== projection.tail.at(-1)?.turnId
|
||||
);
|
||||
const last = projection.tail.at(-1) ?? null;
|
||||
const first = projection.head[0] ?? null;
|
||||
const activeTurnIsAnother = Boolean(
|
||||
input.activeTurnId && last?.turnId && input.activeTurnId !== last.turnId,
|
||||
);
|
||||
// Without head rows, the latest retained turn is live only while the active run is that
|
||||
// turn. A run that has not opened yet carries no id and belongs to the next turn.
|
||||
const isLatestTurnOpen = first
|
||||
? !isTurnBoundary(last, first) && !activeTurnIsAnother
|
||||
: input.isTurnActive && (last?.turnId === undefined || input.activeTurnId === last.turnId);
|
||||
if (
|
||||
!collapsed ||
|
||||
collapsedSource !== projection.tail ||
|
||||
|
||||
@@ -61,6 +61,11 @@ import { returnToTimelineTail } from "./timeline-tail-navigation";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { ToolCallDetailsContent } from "@/components/tool-call-details";
|
||||
import { QuestionFormCard } from "@/components/question-form-card";
|
||||
import {
|
||||
ToolCallExpansionContext,
|
||||
useAgentToolCallExpansion,
|
||||
useExpandedToolCallGroups,
|
||||
} from "@/tool-calls/expansion-context";
|
||||
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
|
||||
import { createStreamPresentation } from "./presentation";
|
||||
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
|
||||
@@ -364,9 +369,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const toolCallExpansion = useAgentToolCallExpansion(agentId);
|
||||
const expandedToolCallGroups = useExpandedToolCallGroups(toolCallExpansion);
|
||||
const [expandedTurnHostIds, setExpandedTurnHostIds] = useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
@@ -431,7 +435,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
useEffect(() => {
|
||||
setIsNearBottom(true);
|
||||
setExpandedInlineToolCallIds(new Set());
|
||||
setExpandedToolCallGroupIds(new Set());
|
||||
setExpandedTurnHostIds(new Set());
|
||||
}, [agentId]);
|
||||
|
||||
@@ -708,17 +711,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none";
|
||||
});
|
||||
|
||||
const setToolCallGroupExpanded = useCallback((groupId: string, expanded: boolean) => {
|
||||
setExpandedToolCallGroupIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (expanded) {
|
||||
next.add(groupId);
|
||||
} else {
|
||||
next.delete(groupId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const setToolCallGroupExpanded = toolCallExpansion.setGroupExpanded;
|
||||
|
||||
const renderUserMessageItem = useCallback(
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
|
||||
@@ -815,7 +808,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
(
|
||||
item: Extract<StreamItem, { kind: "tool_call" }>,
|
||||
isLastInSequence: boolean,
|
||||
maxDetailHeight?: number,
|
||||
options: { maxDetailHeight?: number; standalone?: boolean } = {},
|
||||
) => {
|
||||
const { payload } = item;
|
||||
|
||||
@@ -847,7 +840,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
agentMessage={data.agentMessage}
|
||||
isLastInSequence={isLastInSequence}
|
||||
onOpenFilePath={handleToolCallOpenFile}
|
||||
maxDetailHeight={maxDetailHeight}
|
||||
standalone={options.standalone}
|
||||
maxDetailHeight={options.maxDetailHeight}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -863,7 +857,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
status={data.status}
|
||||
isLastInSequence={isLastInSequence}
|
||||
onOpenFilePath={handleToolCallOpenFile}
|
||||
maxDetailHeight={maxDetailHeight}
|
||||
standalone={options.standalone}
|
||||
maxDetailHeight={options.maxDetailHeight}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -886,7 +881,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
? renderThoughtItem(layoutItem, item)
|
||||
: renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
|
||||
}
|
||||
const expanded = expandedToolCallGroupIds.has(group.run.id);
|
||||
const expanded = expandedToolCallGroups.has(group.run.id);
|
||||
return (
|
||||
<OverviewToolCallGroupView
|
||||
group={group}
|
||||
@@ -898,20 +893,22 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
? group.run.calls.map((call, index) => (
|
||||
<React.Fragment key={call.id}>
|
||||
{call.kind === "thought" ? (
|
||||
// Reasoning is grouped only while Expand thinking is off, so an opened
|
||||
// group shows each thought as a closed row like any other call.
|
||||
<ThoughtSlot
|
||||
itemId={call.id}
|
||||
text={call.text}
|
||||
status={call.status}
|
||||
isLastInSequence={index === group.run.calls.length - 1}
|
||||
defaultExpanded
|
||||
defaultExpanded={false}
|
||||
standalone={false}
|
||||
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
|
||||
/>
|
||||
) : (
|
||||
renderSingleToolCallItem(
|
||||
call,
|
||||
index === group.run.calls.length - 1,
|
||||
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
|
||||
)
|
||||
renderSingleToolCallItem(call, index === group.run.calls.length - 1, {
|
||||
maxDetailHeight: GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
|
||||
standalone: false,
|
||||
})
|
||||
)}
|
||||
</React.Fragment>
|
||||
))
|
||||
@@ -920,7 +917,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
);
|
||||
},
|
||||
[
|
||||
expandedToolCallGroupIds,
|
||||
expandedToolCallGroups,
|
||||
renderThoughtItem,
|
||||
setInlineDetailsExpanded,
|
||||
getToolCallGroup,
|
||||
@@ -1172,15 +1169,18 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
() => ({
|
||||
contentById: presentation.historyGroupUpdatesByHostId,
|
||||
displayStateById: {
|
||||
// An opened lone call also marks its future group open; that row stays unrevised
|
||||
// until a group actually forms there.
|
||||
has: (id: string) =>
|
||||
expandedToolCallGroupIds.has(id) ||
|
||||
(expandedToolCallGroups.has(id) && getToolCallGroup(id) !== undefined) ||
|
||||
expandedTurnHostIds.has(id) ||
|
||||
nestedItemIds.has(id),
|
||||
},
|
||||
globalDisplayState: isMobile,
|
||||
}),
|
||||
[
|
||||
expandedToolCallGroupIds,
|
||||
expandedToolCallGroups,
|
||||
getToolCallGroup,
|
||||
expandedTurnHostIds,
|
||||
isMobile,
|
||||
nestedItemIds,
|
||||
@@ -1205,30 +1205,32 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
<ToolCallSheetProvider>
|
||||
<AssistantSelectionCopySurface style={stylesheet.container}>
|
||||
<MessageOuterSpacingProvider disableOuterSpacing>
|
||||
{streamRenderStrategy.render({
|
||||
readingRowReplacements: presentation.readingRowReplacements,
|
||||
agentId,
|
||||
segments: renderModel.segments,
|
||||
historyRowRevision,
|
||||
liveHeadRowRevision: expandedToolCallGroupIds,
|
||||
boundary,
|
||||
renderers,
|
||||
listEmptyComponent,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
onNearBottomChange: setIsNearBottom,
|
||||
onReadingPositionChange: handleReadingPositionChange,
|
||||
onNearHistoryStart: loadOlder,
|
||||
isLoadingOlderHistory: isLoadingOlder,
|
||||
hasOlderHistory: hasOlder,
|
||||
olderHistoryProgressKey: progressKey,
|
||||
scrollEnabled: streamScrollEnabled,
|
||||
listStyle: stylesheet.list,
|
||||
baseListContentContainerStyle: stylesheet.listContentContainer,
|
||||
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
|
||||
contentMaxWidth,
|
||||
})}
|
||||
<ToolCallExpansionContext.Provider value={toolCallExpansion}>
|
||||
{streamRenderStrategy.render({
|
||||
readingRowReplacements: presentation.readingRowReplacements,
|
||||
agentId,
|
||||
segments: renderModel.segments,
|
||||
historyRowRevision,
|
||||
liveHeadRowRevision: expandedToolCallGroups,
|
||||
boundary,
|
||||
renderers,
|
||||
listEmptyComponent,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
isAuthoritativeHistoryReady,
|
||||
onNearBottomChange: setIsNearBottom,
|
||||
onReadingPositionChange: handleReadingPositionChange,
|
||||
onNearHistoryStart: loadOlder,
|
||||
isLoadingOlderHistory: isLoadingOlder,
|
||||
hasOlderHistory: hasOlder,
|
||||
olderHistoryProgressKey: progressKey,
|
||||
scrollEnabled: streamScrollEnabled,
|
||||
listStyle: stylesheet.list,
|
||||
baseListContentContainerStyle: stylesheet.listContentContainer,
|
||||
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
|
||||
contentMaxWidth,
|
||||
})}
|
||||
</ToolCallExpansionContext.Provider>
|
||||
</MessageOuterSpacingProvider>
|
||||
<ChatOutlineRail
|
||||
prompts={chatOutline.prompts}
|
||||
@@ -1401,6 +1403,7 @@ interface ThoughtSlotProps {
|
||||
status: Extract<StreamItem, { kind: "thought" }>["status"];
|
||||
isLastInSequence: boolean;
|
||||
defaultExpanded: boolean;
|
||||
standalone?: boolean;
|
||||
}
|
||||
|
||||
// Reasoning text is paced the same way assistant text is; see @/hooks/use-revealed-text.
|
||||
@@ -1411,6 +1414,7 @@ function ThoughtSlot({
|
||||
status,
|
||||
isLastInSequence,
|
||||
defaultExpanded,
|
||||
standalone,
|
||||
}: ThoughtSlotProps) {
|
||||
const revealedText = useRevealedText(text, status === "ready" ? "complete" : "streaming");
|
||||
return (
|
||||
@@ -1422,6 +1426,7 @@ function ThoughtSlot({
|
||||
status={status === "ready" ? "completed" : "executing"}
|
||||
isLastInSequence={isLastInSequence}
|
||||
defaultExpanded={defaultExpanded}
|
||||
standalone={standalone}
|
||||
forceInline={defaultExpanded}
|
||||
/>
|
||||
);
|
||||
@@ -1436,7 +1441,9 @@ function ToolCallSlot({
|
||||
(expanded: boolean) => onInlineDetailsExpandedChangeByItemId(itemId, expanded),
|
||||
[onInlineDetailsExpandedChangeByItemId, itemId],
|
||||
);
|
||||
return <ToolCall {...rest} onInlineDetailsExpandedChange={handleExpandedChange} />;
|
||||
return (
|
||||
<ToolCall {...rest} itemId={itemId} onInlineDetailsExpandedChange={handleExpandedChange} />
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Text } from "react-native";
|
||||
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SheetBottomInset } from "./adaptive-modal-sheet";
|
||||
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
const SCREEN_INSETS = { top: 24, right: 0, bottom: 34, left: 0 };
|
||||
|
||||
function BottomInsetProbe() {
|
||||
return <Text testID="content-bottom-inset">{String(useSafeAreaInsets().bottom)}</Text>;
|
||||
}
|
||||
|
||||
describe("SheetBottomInset", () => {
|
||||
it("pads the bottom inset once and leaves none for its content", () => {
|
||||
render(
|
||||
<SafeAreaInsetsContext.Provider value={SCREEN_INSETS}>
|
||||
<SheetBottomInset testID="sheet-body">
|
||||
<BottomInsetProbe />
|
||||
</SheetBottomInset>
|
||||
</SafeAreaInsetsContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("sheet-body").style.paddingBottom).toBe("34px");
|
||||
// Content that pads the inset itself, like the composer dock, must not pad it again.
|
||||
expect(screen.getByTestId("content-bottom-inset").textContent).toBe("0");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ 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 type { DimensionValue, StyleProp, ViewProps, ViewStyle } from "react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
useGlobalWebOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "../lib/overlay-root";
|
||||
import { BottomSheetHandle, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
BottomSheetHandle,
|
||||
useBottomSheetGestureHandlers,
|
||||
type BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { ArrowLeft, Search, X, type LucideIcon } from "lucide-react-native";
|
||||
import {
|
||||
@@ -25,7 +30,7 @@ import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-shee
|
||||
import { ScrollView } from "@/components/ui/scroll-view";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { AdaptiveTextInput } from "@/components/adaptive-text-input";
|
||||
export { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-text-input";
|
||||
|
||||
@@ -40,6 +45,9 @@ export const SHEET_HORIZONTAL_PADDING_SCALE = 6;
|
||||
// glyph should line up with the X must reach the same rail.
|
||||
export const SHEET_HEADER_CLOSE_PADDING_SCALE = 2;
|
||||
|
||||
/** The one height a sheet takes when it shows a whole pane rather than sizing to its content. */
|
||||
export const FULL_SHEET_SNAP_POINTS = ["92%"];
|
||||
|
||||
export interface SheetHeaderSearch {
|
||||
onChange: (value: string) => void;
|
||||
onFocus?: () => void;
|
||||
@@ -244,32 +252,55 @@ 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.
|
||||
*/
|
||||
/** The compact sheet's drag handle: Gorhom's `handleComponent` for every bottom sheet. */
|
||||
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} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content that drags the sheet the way its handle does. A sheet whose body scrolls on its own
|
||||
* turns content panning off and wraps its header in this, so the header still pulls it down.
|
||||
* Render it inside the sheet's content.
|
||||
*/
|
||||
export function SheetDragArea({ children }: { children: ReactNode }) {
|
||||
const { handlePanGestureHandler } = useBottomSheetGestureHandlers();
|
||||
const gesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.shouldCancelWhenOutside(false)
|
||||
.runOnJS(false)
|
||||
.onStart(handlePanGestureHandler.handleOnStart)
|
||||
.onChange(handlePanGestureHandler.handleOnChange)
|
||||
.onEnd(handlePanGestureHandler.handleOnEnd)
|
||||
.onFinalize(handlePanGestureHandler.handleOnFinalize),
|
||||
[handlePanGestureHandler],
|
||||
);
|
||||
return (
|
||||
<ThemedBottomSheetHandle
|
||||
{...props}
|
||||
style={HANDLE_WITH_CONTENT_STYLE}
|
||||
accessible={false}
|
||||
accessibilityRole={null}
|
||||
accessibilityLabel={null}
|
||||
accessibilityHint={null}
|
||||
>
|
||||
{children}
|
||||
</ThemedBottomSheetHandle>
|
||||
<GestureDetector gesture={gesture}>
|
||||
<View>{children}</View>
|
||||
</GestureDetector>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact sheet's content area that reaches the bottom of the screen. It owns the system
|
||||
* navigation inset: it pads it once and tells its content none is left, so content that pads the
|
||||
* inset itself, like the composer dock, sits the same in a sheet as in a tab.
|
||||
*/
|
||||
export function SheetBottomInset({ style, children, ...props }: ViewProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const contentInsets = useMemo(() => ({ ...insets, bottom: 0 }), [insets]);
|
||||
const insetStyle = useMemo(() => [style, { paddingBottom: insets.bottom }], [style, insets]);
|
||||
return (
|
||||
<View {...props} style={insetStyle}>
|
||||
<SafeAreaInsetsContext.Provider value={contentInsets}>
|
||||
{children}
|
||||
</SafeAreaInsetsContext.Provider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -488,6 +519,76 @@ export interface AdaptiveModalSheetProps {
|
||||
contextBridge?: ContextBridge | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The compact sheet's body. It renders on the far side of the sheet's portal so it reads the
|
||||
* screen's bottom inset, not the one left where the sheet was opened: inside a sheet tab, none is.
|
||||
*/
|
||||
function CompactSheetBody({
|
||||
header,
|
||||
onClose,
|
||||
testID,
|
||||
footer,
|
||||
isKeyboardVisible,
|
||||
scrollable,
|
||||
bodyStyle,
|
||||
contentStyle,
|
||||
children,
|
||||
}: {
|
||||
header: SheetHeader;
|
||||
onClose: () => void;
|
||||
testID?: string;
|
||||
footer: ReactNode;
|
||||
isKeyboardVisible: boolean;
|
||||
scrollable: boolean;
|
||||
bodyStyle?: StyleProp<ViewStyle>;
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { contentPaddingBottom = 0, footerPaddingBottom = 0 } = getCompactSheetSafeAreaPadding({
|
||||
isCompact: true,
|
||||
isKeyboardVisible,
|
||||
hasFooter: Boolean(footer),
|
||||
safeAreaBottom: insets.bottom,
|
||||
});
|
||||
// Safe-area clearance is a separate layer: it must not replace the caller's
|
||||
// padding (including an explicit zero), and the footer owns it when present.
|
||||
const bodyClearanceStyle = { paddingBottom: contentPaddingBottom };
|
||||
const footerClearanceStyle = useMemo(
|
||||
() => ({ paddingBottom: footerPaddingBottom }),
|
||||
[footerPaddingBottom],
|
||||
);
|
||||
const footerView = useMemo(
|
||||
() => (footer ? <View style={footerClearanceStyle}>{footer}</View> : null),
|
||||
[footer, footerClearanceStyle],
|
||||
);
|
||||
return (
|
||||
<SheetVisibleFrame footer={footerView}>
|
||||
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
|
||||
<View style={[styles.compactStaticContent, bodyStyle]}>
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
style={styles.bottomSheetVisibleScroll}
|
||||
contentContainerStyle={SCROLL_CONTENT_GROW}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={[styles.contentGrow, bodyClearanceStyle]}>
|
||||
<SheetContent style={[styles.contentGrow, contentStyle]}>{children}</SheetContent>
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={[styles.compactStaticContent, bodyClearanceStyle]}>
|
||||
<SheetContent style={[styles.compactStaticContent, contentStyle]}>
|
||||
{children}
|
||||
</SheetContent>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</SheetVisibleFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdaptiveModalSheet({
|
||||
header,
|
||||
visible,
|
||||
@@ -508,34 +609,11 @@ export function AdaptiveModalSheet({
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isKeyboardVisible = useKeyboardVisibility(visible);
|
||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||
const compactSafeAreaPadding = useMemo(
|
||||
() =>
|
||||
getCompactSheetSafeAreaPadding({
|
||||
isCompact: isMobile,
|
||||
isKeyboardVisible,
|
||||
hasFooter: Boolean(footer),
|
||||
safeAreaBottom: insets.bottom,
|
||||
}),
|
||||
[footer, insets.bottom, isKeyboardVisible, isMobile],
|
||||
);
|
||||
// Safe-area clearance is a separate layer: it must not replace the caller's
|
||||
// padding (including an explicit zero), and the footer owns it when present.
|
||||
const bodyClearanceStyle = { paddingBottom: compactSafeAreaPadding.contentPaddingBottom ?? 0 };
|
||||
const footerClearanceStyle = useMemo(
|
||||
() => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }),
|
||||
[compactSafeAreaPadding.footerPaddingBottom],
|
||||
);
|
||||
const footerView = useMemo(
|
||||
() =>
|
||||
footer ? (
|
||||
<View style={footerClearanceStyle}>
|
||||
<View style={[styles.footer, footerContainerStyle]}>{footer}</View>
|
||||
</View>
|
||||
) : null,
|
||||
[footer, footerClearanceStyle, footerContainerStyle],
|
||||
() => (footer ? <View style={[styles.footer, footerContainerStyle]}>{footer}</View> : null),
|
||||
[footer, footerContainerStyle],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isWeb && visible) {
|
||||
@@ -614,32 +692,6 @@ export function AdaptiveModalSheet({
|
||||
}, [visible, isMobile, onDismiss, shouldRenderWeb]);
|
||||
|
||||
if (isMobile) {
|
||||
const sheetContent = (
|
||||
<>
|
||||
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
|
||||
<View style={[styles.compactStaticContent, bodyStyle]}>
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
style={styles.bottomSheetVisibleScroll}
|
||||
contentContainerStyle={SCROLL_CONTENT_GROW}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={[styles.contentGrow, bodyClearanceStyle]}>
|
||||
<SheetContent style={[styles.contentGrow, contentStyle]}>{children}</SheetContent>
|
||||
</View>
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={[styles.compactStaticContent, bodyClearanceStyle]}>
|
||||
<SheetContent style={[styles.compactStaticContent, contentStyle]}>
|
||||
{children}
|
||||
</SheetContent>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
@@ -658,7 +710,18 @@ export function AdaptiveModalSheet({
|
||||
accessible={false}
|
||||
presentation={presentation}
|
||||
>
|
||||
<SheetVisibleFrame footer={footerView}>{sheetContent}</SheetVisibleFrame>
|
||||
<CompactSheetBody
|
||||
header={header}
|
||||
onClose={onClose}
|
||||
testID={testID}
|
||||
footer={footerView}
|
||||
isKeyboardVisible={isKeyboardVisible}
|
||||
scrollable={scrollable}
|
||||
bodyStyle={bodyStyle}
|
||||
contentStyle={contentStyle}
|
||||
>
|
||||
{children}
|
||||
</CompactSheetBody>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ vi.mock("react-i18next", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("react-native-safe-area-context", () => ({
|
||||
SafeAreaProvider: ({ children }: { children?: React.ReactNode }) => children,
|
||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Modal, Pressable, Text, View } from "react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
@@ -28,7 +28,6 @@ const LIGHTBOX_FIT = { padding: SPACING[4], maxWidth: 960, maxHeight: 640 };
|
||||
|
||||
export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const metadata = source?.type === "attachment" ? source.metadata : null;
|
||||
const attachmentUrl = useAttachmentPreviewUrl(metadata);
|
||||
const url = source?.type === "uri" ? source.uri : attachmentUrl;
|
||||
@@ -56,18 +55,6 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
onKeyDown: handleWebOverlayKeyDown,
|
||||
});
|
||||
|
||||
const contentLayerStyle = useMemo(
|
||||
() => [
|
||||
styles.contentLayer,
|
||||
{
|
||||
paddingTop: insets.top,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingLeft: insets.left,
|
||||
},
|
||||
],
|
||||
[insets.bottom, insets.left, insets.right, insets.top],
|
||||
);
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -91,17 +78,19 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
return (
|
||||
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
||||
<ModalRoot style={styles.root}>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View ref={setWebOverlayScope} style={styles.root}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("message.attachments.dismissImage")}
|
||||
onPress={onClose}
|
||||
style={styles.backdrop}
|
||||
/>
|
||||
<View pointerEvents="box-none" style={contentLayerStyle}>
|
||||
<View pointerEvents="box-none" style={styles.imageArea}>
|
||||
{/* The modal is its own window: its insets are the window's, not the ones left where the
|
||||
image was opened (inside a sheet tab, the bottom one is already spent). */}
|
||||
<SafeAreaProvider>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View ref={setWebOverlayScope} style={styles.root}>
|
||||
<Pressable
|
||||
testID="attachment-lightbox-backdrop"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("message.attachments.dismissImage")}
|
||||
onPress={onClose}
|
||||
style={styles.backdrop}
|
||||
/>
|
||||
<LightboxImageArea>
|
||||
{hasError ? (
|
||||
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
|
||||
) : (
|
||||
@@ -117,15 +106,38 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
uri={url}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</LightboxImageArea>
|
||||
</View>
|
||||
</View>
|
||||
</WindowChromeRootRegion>
|
||||
</WindowChromeRootRegion>
|
||||
</SafeAreaProvider>
|
||||
</ModalRoot>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function LightboxImageArea({ children }: { children: ReactNode }) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const style = useMemo(
|
||||
() => [
|
||||
styles.contentLayer,
|
||||
{
|
||||
paddingTop: insets.top,
|
||||
paddingRight: insets.right,
|
||||
paddingBottom: insets.bottom,
|
||||
paddingLeft: insets.left,
|
||||
},
|
||||
],
|
||||
[insets.bottom, insets.left, insets.right, insets.top],
|
||||
);
|
||||
return (
|
||||
<View pointerEvents="box-none" style={style}>
|
||||
<View pointerEvents="box-none" style={styles.imageArea}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
|
||||
@@ -27,6 +27,7 @@ import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
|
||||
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
|
||||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||
import { useToolCallExpanded } from "@/tool-calls/expansion-context";
|
||||
import { useTargetAgentTitle } from "@/tool-calls/use-target-agent-title";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
@@ -2241,6 +2242,9 @@ interface ToolCallProps {
|
||||
defaultExpanded?: boolean;
|
||||
forceInline?: boolean;
|
||||
maxDetailHeight?: number;
|
||||
itemId: string;
|
||||
/** Shown as its own row rather than inside a tool call group. */
|
||||
standalone?: boolean;
|
||||
}
|
||||
|
||||
export const ToolCall = memo(function ToolCall({
|
||||
@@ -2262,9 +2266,15 @@ export const ToolCall = memo(function ToolCall({
|
||||
defaultExpanded,
|
||||
forceInline = false,
|
||||
maxDetailHeight = 400,
|
||||
itemId,
|
||||
standalone = true,
|
||||
}: ToolCallProps) {
|
||||
const { openToolCall } = useToolCallSheet();
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded ?? false);
|
||||
const { expanded: isExpanded, toggle: toggleExpanded } = useToolCallExpanded({
|
||||
callId: itemId,
|
||||
defaultExpanded: defaultExpanded ?? false,
|
||||
standalone,
|
||||
});
|
||||
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const shouldRenderInline = !isMobile || forceInline;
|
||||
@@ -2319,10 +2329,11 @@ export const ToolCall = memo(function ToolCall({
|
||||
showLoadingSkeleton: presentation.isLoadingDetails,
|
||||
});
|
||||
} else {
|
||||
setIsExpanded((prev) => !prev);
|
||||
toggleExpanded();
|
||||
}
|
||||
}, [
|
||||
shouldRenderInline,
|
||||
toggleExpanded,
|
||||
openToolCall,
|
||||
toolName,
|
||||
presentation.detail,
|
||||
@@ -2428,5 +2439,7 @@ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) {
|
||||
if (previous.defaultExpanded !== next.defaultExpanded) return false;
|
||||
if (previous.forceInline !== next.forceInline) return false;
|
||||
if (previous.maxDetailHeight !== next.maxDetailHeight) return false;
|
||||
if (previous.itemId !== next.itemId) return false;
|
||||
if (previous.standalone !== next.standalone) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1692,7 +1692,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
opacity: 0,
|
||||
},
|
||||
emptyState: {
|
||||
paddingVertical: theme.spacing[4],
|
||||
padding: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
|
||||
@@ -168,6 +168,7 @@ export function Button({
|
||||
trailing,
|
||||
style,
|
||||
textStyle,
|
||||
numberOfLines,
|
||||
disabled,
|
||||
loading = false,
|
||||
accessibilityRole,
|
||||
@@ -182,6 +183,7 @@ export function Button({
|
||||
trailing?: ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
numberOfLines?: number;
|
||||
loading?: boolean;
|
||||
}
|
||||
>) {
|
||||
@@ -277,7 +279,11 @@ export function Button({
|
||||
iconSize={buttonIconSize[size]}
|
||||
uniProps={resolveIconMapping()}
|
||||
/>
|
||||
{children != null ? <Text style={resolvedTextStyle}>{children}</Text> : null}
|
||||
{children != null ? (
|
||||
<Text numberOfLines={numberOfLines} style={resolvedTextStyle}>
|
||||
{children}
|
||||
</Text>
|
||||
) : null}
|
||||
{trailing}
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
@@ -938,7 +938,6 @@ interface MobileBodyProps {
|
||||
handleSelect: (id: string) => void;
|
||||
renderOption: RenderOptionFn | undefined;
|
||||
children: ReactNode;
|
||||
safeAreaBottom: number;
|
||||
}
|
||||
|
||||
function MobileComboboxBody(props: MobileBodyProps): ReactElement | null {
|
||||
@@ -961,21 +960,6 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
|
||||
() => [styles.comboboxTitle, { color: props.titleColor }],
|
||||
[props.titleColor],
|
||||
);
|
||||
const { footer, safeAreaBottom } = props;
|
||||
const frameStyle = useMemo(
|
||||
() => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }],
|
||||
[footer, safeAreaBottom],
|
||||
);
|
||||
const footerStyle = useMemo(
|
||||
() => [styles.footer, { paddingBottom: safeAreaBottom }],
|
||||
[safeAreaBottom],
|
||||
);
|
||||
|
||||
const footerView = useMemo(
|
||||
() => (footer ? <View style={footerStyle}>{footer}</View> : undefined),
|
||||
[footer, footerStyle],
|
||||
);
|
||||
|
||||
const body = props.hasChildren ? (
|
||||
props.children
|
||||
) : (
|
||||
@@ -1006,51 +990,74 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
|
||||
keyboardBlurBehavior="none"
|
||||
presentation={props.presentation}
|
||||
>
|
||||
<SheetVisibleFrame footer={footerView}>
|
||||
<View style={frameStyle}>
|
||||
{props.header ? (
|
||||
<SheetHeaderView header={props.header} onClose={props.onClose} />
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text key={props.titleColor} style={comboboxTitleStyle}>
|
||||
{props.title}
|
||||
</Text>
|
||||
</View>
|
||||
{props.stickyHeader}
|
||||
{!props.hasChildren && props.searchable ? (
|
||||
<SearchInput
|
||||
placeholder={props.searchPlaceholder}
|
||||
onChangeText={props.setSearchQueryWithCallback}
|
||||
onSubmitEditing={props.handleSubmitSearch}
|
||||
autoFocus={false}
|
||||
useBottomSheetInput
|
||||
resetKey={props.searchResetKey}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
|
||||
body
|
||||
) : (
|
||||
<BottomSheetScrollView
|
||||
style={styles.mobileSheetBody}
|
||||
contentContainerStyle={[
|
||||
styles.comboboxScrollContent,
|
||||
props.mobileChildrenContentContainerStyle,
|
||||
]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{body}
|
||||
</BottomSheetScrollView>
|
||||
)}
|
||||
</View>
|
||||
</SheetVisibleFrame>
|
||||
<ComboboxSheetFrame footer={props.footer}>
|
||||
{props.header ? (
|
||||
<SheetHeaderView header={props.header} onClose={props.onClose} />
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text key={props.titleColor} style={comboboxTitleStyle}>
|
||||
{props.title}
|
||||
</Text>
|
||||
</View>
|
||||
{props.stickyHeader}
|
||||
{!props.hasChildren && props.searchable ? (
|
||||
<SearchInput
|
||||
placeholder={props.searchPlaceholder}
|
||||
onChangeText={props.setSearchQueryWithCallback}
|
||||
onSubmitEditing={props.handleSubmitSearch}
|
||||
autoFocus={false}
|
||||
useBottomSheetInput
|
||||
resetKey={props.searchResetKey}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
|
||||
body
|
||||
) : (
|
||||
<BottomSheetScrollView
|
||||
style={styles.mobileSheetBody}
|
||||
contentContainerStyle={[
|
||||
styles.comboboxScrollContent,
|
||||
props.mobileChildrenContentContainerStyle,
|
||||
]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{body}
|
||||
</BottomSheetScrollView>
|
||||
)}
|
||||
</ComboboxSheetFrame>
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendered on the far side of the sheet's portal so it clears the screen's bottom inset, not the
|
||||
* one left where the combobox was opened: inside a sheet tab, none is.
|
||||
*/
|
||||
function ComboboxSheetFrame({ footer, children }: { footer?: ReactNode; children: ReactNode }) {
|
||||
const { bottom: safeAreaBottom } = useSafeAreaInsets();
|
||||
const frameStyle = useMemo(
|
||||
() => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }],
|
||||
[footer, safeAreaBottom],
|
||||
);
|
||||
const footerStyle = useMemo(
|
||||
() => [styles.footer, { paddingBottom: safeAreaBottom }],
|
||||
[safeAreaBottom],
|
||||
);
|
||||
const footerView = useMemo(
|
||||
() => (footer ? <View style={footerStyle}>{footer}</View> : undefined),
|
||||
[footer, footerStyle],
|
||||
);
|
||||
return (
|
||||
<SheetVisibleFrame footer={footerView}>
|
||||
<View style={frameStyle}>{children}</View>
|
||||
</SheetVisibleFrame>
|
||||
);
|
||||
}
|
||||
|
||||
interface DesktopBodyProps {
|
||||
overlayLayer: number;
|
||||
isOpen: boolean;
|
||||
@@ -1327,7 +1334,6 @@ export function Combobox({
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const active = useRetainedPanelActive();
|
||||
const floatingLayer = useOverlayLayer("floating");
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const titleColor = theme.colors.foreground;
|
||||
const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition);
|
||||
const isDesktopAboveSearch = resolveIsDesktopAboveSearch(isMobile, effectiveOptionsPosition);
|
||||
@@ -1608,7 +1614,6 @@ export function Combobox({
|
||||
emptyText={resolvedEmptyText}
|
||||
handleSelect={handleSelect}
|
||||
renderOption={renderOption}
|
||||
safeAreaBottom={safeAreaInsets.bottom}
|
||||
>
|
||||
{children}
|
||||
</MobileComboboxBody>
|
||||
|
||||
@@ -123,19 +123,25 @@ export function useIsolatedBottomSheetVisibility({
|
||||
visible,
|
||||
isEnabled,
|
||||
onClose,
|
||||
onBack,
|
||||
}: {
|
||||
visible: boolean;
|
||||
isEnabled?: boolean;
|
||||
onClose: () => void;
|
||||
/** Offered Android Back before it closes the sheet; returns true when it handled the press. */
|
||||
onBack?: () => boolean;
|
||||
}) {
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
const onBackRef = useRef(onBack);
|
||||
onBackRef.current = onBack;
|
||||
|
||||
const tracker = useMemo(
|
||||
() =>
|
||||
createBottomSheetVisibilityTracker({
|
||||
onClose: () => onCloseRef.current(),
|
||||
backPress: systemBackPress,
|
||||
onBack: () => onBackRef.current?.() ?? false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -204,6 +204,31 @@ describe("bottom sheet visibility tracker", () => {
|
||||
expect(context.sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]);
|
||||
});
|
||||
|
||||
it("lets the sheet step back through its own pages before Back dismisses it", () => {
|
||||
const sheet = new FakeBottomSheet();
|
||||
const backPress = new FakeBackPress();
|
||||
let openPages = 1;
|
||||
const tracker = createBottomSheetVisibilityTracker({
|
||||
onClose: () => {},
|
||||
onBack: () => {
|
||||
if (openPages === 0) return false;
|
||||
openPages -= 1;
|
||||
return true;
|
||||
},
|
||||
backPress,
|
||||
});
|
||||
tracker.attachController(sheet);
|
||||
tracker.syncDesired({ visible: true });
|
||||
tracker.handleSheetIndexChange(0);
|
||||
|
||||
expect(backPress.press()).toBe(true);
|
||||
expect(openPages).toBe(0);
|
||||
expect(sheet.events).toEqual([{ type: "present" }]);
|
||||
|
||||
expect(backPress.press()).toBe(true);
|
||||
expect(sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]);
|
||||
});
|
||||
|
||||
it("leaves Back to the navigator before the sheet is on screen and after it closes", () => {
|
||||
const context = setup();
|
||||
context.tracker.attachController(context.sheet);
|
||||
|
||||
@@ -36,6 +36,11 @@ type BottomSheetPhase = "closed" | "presenting" | "presented" | "dismissing";
|
||||
export function createBottomSheetVisibilityTracker(opts: {
|
||||
onClose: () => void;
|
||||
backPress?: BackPressSource;
|
||||
/**
|
||||
* Offered the Back press before the sheet takes it as a close. Returns true when it handled
|
||||
* the press itself, such as stepping back a page inside the sheet.
|
||||
*/
|
||||
onBack?: () => boolean;
|
||||
}): BottomSheetVisibilityTracker {
|
||||
let controller: BottomSheetController | null = null;
|
||||
let visible = false;
|
||||
@@ -60,6 +65,7 @@ export function createBottomSheetVisibilityTracker(opts: {
|
||||
}
|
||||
releaseBackPress =
|
||||
opts.backPress?.subscribe(() => {
|
||||
if (opts.onBack?.()) return true;
|
||||
dismiss();
|
||||
return true;
|
||||
}) ?? null;
|
||||
|
||||
@@ -12,6 +12,8 @@ export { MenuRoot, MenuTrigger, type MenuTriggerProps, type MenuTriggerState } f
|
||||
export {
|
||||
MenuSurface,
|
||||
useMenuSurface,
|
||||
type MenuFillPageDefinition,
|
||||
type MenuListPageDefinition,
|
||||
type MenuPageDefinition,
|
||||
type MenuSurfaceProps,
|
||||
} from "./menu-surface";
|
||||
|
||||
@@ -61,3 +61,38 @@ describe("useMenuState selectItem elsewhere", () => {
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useMenuState pages", () => {
|
||||
it("pops the open page on Back and leaves Back at the root to the surface", () => {
|
||||
const { result } = renderHook(() => useMenuState({ defaultOpen: true }));
|
||||
act(() => result.current.openSub({ id: "row", depth: 0 }));
|
||||
|
||||
let handled = false;
|
||||
act(() => {
|
||||
handled = result.current.goBack();
|
||||
});
|
||||
expect(handled).toBe(true);
|
||||
expect(result.current.path).toEqual([]);
|
||||
|
||||
act(() => {
|
||||
handled = result.current.goBack();
|
||||
});
|
||||
expect(handled).toBe(false);
|
||||
expect(result.current.open).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to the list when the open page's definition goes away", () => {
|
||||
const { result } = renderHook(() => useMenuState({ defaultOpen: true }));
|
||||
act(() => result.current.openSub({ id: "row", depth: 0 }));
|
||||
|
||||
const keepNoPage = () => false;
|
||||
act(() => result.current.prunePages(keepNoPage));
|
||||
|
||||
expect(result.current.path).toEqual([]);
|
||||
let handled = true;
|
||||
act(() => {
|
||||
handled = result.current.goBack();
|
||||
});
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
goBack as goBackPath,
|
||||
MENU_ROOT_PATH,
|
||||
openSubPage,
|
||||
pruneMissingPages,
|
||||
type MenuPath,
|
||||
} from "./menu-navigation";
|
||||
import type { Rect } from "../anchor";
|
||||
@@ -40,7 +41,10 @@ export interface MenuContextValue {
|
||||
path: MenuPath;
|
||||
openSub: (sub: { id: string; depth: number }) => void;
|
||||
closeSub: (depth: number) => void;
|
||||
goBack: () => void;
|
||||
/** Pops the open page. False at the root, where Back is the surface's to answer. */
|
||||
goBack: () => boolean;
|
||||
/** Closes any open page that no longer has a definition, falling back to its parent. */
|
||||
prunePages: (hasPage: (id: string) => boolean) => void;
|
||||
}
|
||||
|
||||
const MenuContext = createContext<MenuContextValue | null>(null);
|
||||
@@ -160,7 +164,13 @@ export function useMenuState({
|
||||
}, []);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (path.length === 0) return false;
|
||||
setPath((current) => goBackPath(current));
|
||||
return true;
|
||||
}, [path.length]);
|
||||
|
||||
const prunePages = useCallback((hasPage: (id: string) => boolean) => {
|
||||
setPath((current) => pruneMissingPages(current, hasPage));
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
@@ -176,7 +186,19 @@ export function useMenuState({
|
||||
openSub,
|
||||
closeSub,
|
||||
goBack,
|
||||
prunePages,
|
||||
}),
|
||||
[isOpen, setOpen, selectItem, anchorRect, presentation, path, openSub, closeSub, goBack],
|
||||
[
|
||||
isOpen,
|
||||
setOpen,
|
||||
selectItem,
|
||||
anchorRect,
|
||||
presentation,
|
||||
path,
|
||||
openSub,
|
||||
closeSub,
|
||||
goBack,
|
||||
prunePages,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
isSubPageOpen,
|
||||
MENU_ROOT_PATH,
|
||||
openSubPage,
|
||||
pruneMissingPages,
|
||||
} from "./menu-navigation";
|
||||
|
||||
describe("openSubPage", () => {
|
||||
@@ -73,3 +74,15 @@ describe("isSubPageOpen", () => {
|
||||
expect(isSubPageOpen(path, { id: "grouping", depth: 0 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pruneMissingPages", () => {
|
||||
it("falls back to the deepest page that still exists", () => {
|
||||
expect(pruneMissingPages(["list", "row"], (id) => id === "list")).toEqual(["list"]);
|
||||
expect(pruneMissingPages(["list", "row"], (id) => id === "row")).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns the same path when every page exists", () => {
|
||||
const path = ["list", "row"];
|
||||
expect(pruneMissingPages(path, () => true)).toBe(path);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,16 @@ export function closeSubPage(path: MenuPath, depth: number): MenuPath {
|
||||
return path.slice(0, depth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuts the path back to the deepest page that still has a definition. A page can lose its
|
||||
* definition while it is open (a subagent's page goes with its row when the subagent is
|
||||
* archived), and the pages it opened go with it.
|
||||
*/
|
||||
export function pruneMissingPages(path: MenuPath, hasPage: (id: string) => boolean): MenuPath {
|
||||
const missing = path.findIndex((id) => !hasPage(id));
|
||||
return missing === -1 ? path : path.slice(0, missing);
|
||||
}
|
||||
|
||||
export function goBack(path: MenuPath): MenuPath {
|
||||
if (path.length === 0) return path;
|
||||
return path.slice(0, -1);
|
||||
|
||||
@@ -19,6 +19,11 @@ import {
|
||||
useIsolatedBottomSheetVisibility,
|
||||
type ContextBridge,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import {
|
||||
FULL_SHEET_SNAP_POINTS,
|
||||
SheetBackground,
|
||||
SheetHandle,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import { SPACING, type Theme } from "@/styles/theme";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useMenuContext, MenuContextProvider } from "./menu-context";
|
||||
@@ -32,24 +37,6 @@ import type { KeyboardFocusScope } from "@/keyboard/actions";
|
||||
const ThemedChevronLeft = withUnistyles(ChevronLeft);
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
// `backgroundStyle` and `handleIndicatorStyle` are style-shaped props the Babel plugin does not
|
||||
// track, so the sheet is wrapped rather than reading the theme through a hook.
|
||||
// See docs/unistyles.md.
|
||||
const ThemedBottomSheetModal = withUnistyles(IsolatedBottomSheetModal, (theme) => ({
|
||||
backgroundStyle: {
|
||||
borderTopLeftRadius: theme.borderRadius.xl,
|
||||
borderTopRightRadius: theme.borderRadius.xl,
|
||||
borderWidth: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
handleIndicatorStyle: {
|
||||
width: 36,
|
||||
height: 4,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
|
||||
/** How long the pointer must rest on a submenu row before its flyout opens. */
|
||||
const HOVER_OPEN_DELAY_MS = 90;
|
||||
/**
|
||||
@@ -65,7 +52,7 @@ const HOVER_CLOSE_GRACE_MS = 260;
|
||||
*/
|
||||
const SUBMENU_OVERLAP = 5;
|
||||
|
||||
export interface MenuPageDefinition {
|
||||
export interface MenuListPageDefinition {
|
||||
id: string;
|
||||
title: string;
|
||||
content: ReactNode;
|
||||
@@ -78,6 +65,24 @@ export interface MenuPageDefinition {
|
||||
* surface stops closing on hover, since its parent leads back to the same dismissal.
|
||||
*/
|
||||
hoverIntent?: boolean;
|
||||
fill?: false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A sheet page that takes the sheet's full height and draws everything itself, header included:
|
||||
* no back header, no scroller, no content panning. Only the sheet presentation opens one; a
|
||||
* popover never has a trigger for it.
|
||||
*/
|
||||
export interface MenuFillPageDefinition {
|
||||
id: string;
|
||||
fill: true;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
export type MenuPageDefinition = MenuListPageDefinition | MenuFillPageDefinition;
|
||||
|
||||
function pageHoverIntent(page: MenuPageDefinition | undefined): boolean {
|
||||
return !page || page.fill === true || page.hoverIntent !== false;
|
||||
}
|
||||
|
||||
interface MenuSurfaceContextValue {
|
||||
@@ -126,8 +131,15 @@ 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, open, setOpen } = useMenuContext("MenuSurface");
|
||||
const { presentation, open, setOpen, path, prunePages } = useMenuContext("MenuSurface");
|
||||
const active = useRetainedPanelActive();
|
||||
// An open page whose definition went away (its row was removed) falls back to its parent, so
|
||||
// neither a render nor a later Back walks a path to a page that no longer exists.
|
||||
const { pages } = props;
|
||||
useEffect(() => {
|
||||
if (path.length === 0) return;
|
||||
prunePages((id) => pages?.some((page) => page.id === id) ?? false);
|
||||
}, [pages, path, prunePages]);
|
||||
// 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.
|
||||
@@ -234,11 +246,11 @@ function MenuPopoverSurface({
|
||||
// `hoverIntent: false` takes a page off the pointer entirely — it is not opened by resting on
|
||||
// its trigger, and while it is open nothing on this surface closes on a pointer leaving it.
|
||||
const hoverValue = useMemo<MenuSurfaceContextValue>(() => {
|
||||
const locked = openPages.some(({ page }) => page.hoverIntent === false);
|
||||
const locked = openPages.some(({ page }) => !pageHoverIntent(page));
|
||||
return {
|
||||
...surfaceValue,
|
||||
hoverOpen: (sub) => {
|
||||
if (pages.find((page) => page.id === sub.id)?.hoverIntent === false) return;
|
||||
if (!pageHoverIntent(pages.find((page) => page.id === sub.id))) return;
|
||||
surfaceValue.hoverOpen(sub);
|
||||
},
|
||||
hoverClose: (depth) => {
|
||||
@@ -353,17 +365,6 @@ function MenuSheetSurface({
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
const menu = useMenuContext("MenuSurface");
|
||||
const { value: surfaceValue } = useSubAnchors();
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
|
||||
const sheetScrollContentStyle = useMemo(
|
||||
() => ({
|
||||
paddingBottom: getMenuSheetBottomPadding({
|
||||
safeAreaBottom: safeAreaInsets.bottom,
|
||||
breathingRoom: SPACING[1],
|
||||
}),
|
||||
}),
|
||||
[safeAreaInsets.bottom],
|
||||
);
|
||||
const sheetDataSet = useMemo(
|
||||
() => (keyboardFocusScope ? { keyboardScope: keyboardFocusScope } : undefined),
|
||||
[keyboardFocusScope],
|
||||
@@ -374,11 +375,15 @@ function MenuSheetSurface({
|
||||
visible: menu.open,
|
||||
isEnabled: true,
|
||||
onClose: handleClose,
|
||||
// Android Back steps back through the pages before it closes the sheet.
|
||||
onBack: menu.goBack,
|
||||
});
|
||||
|
||||
const openPageId = currentPageId(menu.path);
|
||||
const openPage = openPageId ? pages.find((page) => page.id === openPageId) : null;
|
||||
const depth = menu.path.length;
|
||||
const fillPage = openPage?.fill === true ? openPage : null;
|
||||
const listPage = openPage?.fill === true ? null : openPage;
|
||||
|
||||
// The sheet's content is teleported out of this subtree, so both menu contexts have to be
|
||||
// rebuilt on the other side. Providing them around the modal instead would put them on the
|
||||
@@ -393,12 +398,18 @@ function MenuSheetSurface({
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemedBottomSheetModal
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={contextBridge}
|
||||
backgroundComponent={SheetBackground}
|
||||
handleComponent={SheetHandle}
|
||||
// Content-sized rather than fixed snap points: a pushed page is rarely the same height
|
||||
// as the page it replaced, and a fixed sheet would either clip it or leave dead space.
|
||||
enableDynamicSizing
|
||||
// A fill page is the exception: it takes the height every full sheet has, and its own
|
||||
// content scrolls, so dragging it would fight that scroll.
|
||||
enableDynamicSizing={!fillPage}
|
||||
snapPoints={fillPage ? FULL_SHEET_SNAP_POINTS : undefined}
|
||||
enableContentPanningGesture={!fillPage}
|
||||
maxDynamicContentSize={windowHeight * 0.8}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
@@ -412,28 +423,64 @@ function MenuSheetSurface({
|
||||
keyboardBehavior="interactive"
|
||||
keyboardBlurBehavior="restore"
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
dataSet={sheetDataSet}
|
||||
contentContainerStyle={sheetScrollContentStyle}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID={testID ? `${testID}-content` : undefined}
|
||||
>
|
||||
{openPage ? (
|
||||
<>
|
||||
<MenuSheetHeader title={openPage.title} onBack={menu.goBack} />
|
||||
<MenuPage depth={depth}>{openPage.content}</MenuPage>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{sheetTitle ? (
|
||||
<MenuSheetHeader title={sheetTitle} onBack={null} trailing={sheetTrailing} />
|
||||
) : null}
|
||||
<MenuPage depth={0}>{children}</MenuPage>
|
||||
</>
|
||||
)}
|
||||
</BottomSheetScrollView>
|
||||
</ThemedBottomSheetModal>
|
||||
{fillPage ? (
|
||||
<View style={styles.fillPage} testID={testID ? `${testID}-${fillPage.id}` : undefined}>
|
||||
{fillPage.content}
|
||||
</View>
|
||||
) : (
|
||||
<MenuSheetScrollView dataSet={sheetDataSet} testID={testID}>
|
||||
{listPage ? (
|
||||
<>
|
||||
<MenuSheetHeader title={listPage.title} onBack={menu.goBack} />
|
||||
<MenuPage depth={depth}>{listPage.content}</MenuPage>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{sheetTitle ? (
|
||||
<MenuSheetHeader title={sheetTitle} onBack={null} trailing={sheetTrailing} />
|
||||
) : null}
|
||||
<MenuPage depth={0}>{children}</MenuPage>
|
||||
</>
|
||||
)}
|
||||
</MenuSheetScrollView>
|
||||
)}
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendered on the far side of the sheet's portal so it clears the screen's bottom inset, not the
|
||||
* one left where the menu was opened: inside a sheet tab, none is.
|
||||
*/
|
||||
function MenuSheetScrollView({
|
||||
dataSet,
|
||||
testID,
|
||||
children,
|
||||
}: {
|
||||
dataSet: { keyboardScope: KeyboardFocusScope } | undefined;
|
||||
testID?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const contentStyle = useMemo(
|
||||
() => ({
|
||||
paddingBottom: getMenuSheetBottomPadding({
|
||||
safeAreaBottom: safeAreaInsets.bottom,
|
||||
breathingRoom: SPACING[1],
|
||||
}),
|
||||
}),
|
||||
[safeAreaInsets.bottom],
|
||||
);
|
||||
return (
|
||||
<BottomSheetScrollView
|
||||
dataSet={dataSet}
|
||||
contentContainerStyle={contentStyle}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID={testID ? `${testID}-content` : undefined}
|
||||
>
|
||||
{children}
|
||||
</BottomSheetScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -470,6 +517,10 @@ function MenuSheetHeader({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
fillPage: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
sheetHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useMemo, type ReactNode } from "react";
|
||||
import { View, type LayoutChangeEvent } from "react-native";
|
||||
import { View, type LayoutChangeEvent, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
@@ -8,23 +8,29 @@ import { EdgeFade } from "@/components/ui/edge-fade";
|
||||
import { useProviderIcon } from "@/components/provider-icons";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
const PREVIEW_HEIGHT = 240;
|
||||
// Native View still clips with "scroll", but Yoga measures its children without
|
||||
// the height cap. "hidden" caps the measured body too, hiding the expand control.
|
||||
const previewStyle: ViewStyle = {
|
||||
maxHeight: PREVIEW_HEIGHT,
|
||||
overflow: isNative ? "scroll" : "hidden",
|
||||
};
|
||||
|
||||
export function MessagePreview({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [height, setHeight] = useState(0);
|
||||
const overflowing = height > PREVIEW_HEIGHT;
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
const measure = useCallback(
|
||||
(event: LayoutChangeEvent) => setHeight(event.nativeEvent.layout.height),
|
||||
(event: LayoutChangeEvent) => setOverflowing(event.nativeEvent.layout.height > PREVIEW_HEIGHT),
|
||||
[],
|
||||
);
|
||||
const toggle = useCallback(() => setExpanded((value) => !value), []);
|
||||
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
|
||||
return (
|
||||
<>
|
||||
<View style={!expanded && styles.preview}>
|
||||
<View style={!expanded && previewStyle}>
|
||||
<View style={styles.body} onLayout={measure}>
|
||||
{children}
|
||||
</View>
|
||||
@@ -74,6 +80,8 @@ export function MessageSender({
|
||||
size="sm"
|
||||
leftIcon={renderProviderIcon}
|
||||
style={styles.sender}
|
||||
textStyle={styles.senderText}
|
||||
numberOfLines={1}
|
||||
disabled={!host}
|
||||
onPress={openSender}
|
||||
>
|
||||
@@ -83,9 +91,9 @@ export function MessageSender({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
preview: { maxHeight: PREVIEW_HEIGHT, overflow: "hidden" },
|
||||
body: { flexShrink: 0 },
|
||||
sender: { paddingRight: 0, borderRightWidth: 0 },
|
||||
sender: { paddingRight: 0, borderRightWidth: 0, maxWidth: "100%" },
|
||||
senderText: { flexShrink: 1 },
|
||||
toggle: { alignSelf: "flex-start", paddingHorizontal: 0, minHeight: 0, borderWidth: 0 },
|
||||
expandedToggle: { marginTop: theme.spacing[2] },
|
||||
fade: { position: "absolute", bottom: 0, left: 0, right: 0, height: theme.spacing[12] },
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
useWorkspaceAttachmentsStore,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||
import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { COMPACT_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
@@ -390,7 +390,7 @@ export function WorkspaceDraftAgentTab({
|
||||
const editingSession = useComposerEditingSession("chat");
|
||||
const isComposerFullscreen = useComposerFullscreen(editingSession);
|
||||
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
|
||||
COMPACT_FORM_FACTOR_WIDTH,
|
||||
COMPACT_CONTENT_WIDTH,
|
||||
{ initialIsBelow: isCompactFormFactor },
|
||||
);
|
||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
MenuSurface,
|
||||
MenuTrigger,
|
||||
useMenuContext,
|
||||
useMenuDepth,
|
||||
type MenuPageDefinition,
|
||||
type MenuTriggerState,
|
||||
} from "@/components/ui/menu";
|
||||
import { StatusRing } from "@/components/status-ring";
|
||||
@@ -61,6 +63,8 @@ export interface ComposerTrackPillProps {
|
||||
accessibilityLabel?: string;
|
||||
/** Panel body. Rendered into a popover on wide screens and a sheet on compact ones. */
|
||||
children: ReactNode;
|
||||
/** Pages a row can open in place of the list (`ComposerTrackRow` `opensPage`). */
|
||||
pages?: readonly MenuPageDefinition[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,6 +89,7 @@ export function ComposerTrackPill({
|
||||
testID,
|
||||
accessibilityLabel,
|
||||
children,
|
||||
pages,
|
||||
}: ComposerTrackPillProps): ReactElement {
|
||||
return (
|
||||
<MenuRoot compactMode="sheet">
|
||||
@@ -98,6 +103,7 @@ export function ComposerTrackPill({
|
||||
align="start"
|
||||
offset={PANEL_OFFSET}
|
||||
sheetTitle={panelTitle}
|
||||
pages={pages}
|
||||
minWidth={PANEL_MIN_WIDTH}
|
||||
maxWidth={PANEL_MAX_WIDTH}
|
||||
maxHeight={PANEL_MAX_HEIGHT}
|
||||
@@ -199,6 +205,11 @@ export interface ComposerTrackRowProps {
|
||||
* lands inside the panel is not.
|
||||
*/
|
||||
closeOnSelect?: boolean;
|
||||
/**
|
||||
* A page of the panel this row opens in place of the list, instead of running `onPress`. The
|
||||
* panel stays open: Back returns to the list.
|
||||
*/
|
||||
opensPage?: string;
|
||||
disabled?: boolean;
|
||||
accessibilityLabel?: string;
|
||||
testID?: string;
|
||||
@@ -220,18 +231,23 @@ export function ComposerTrackRow({
|
||||
children,
|
||||
onPress,
|
||||
closeOnSelect = true,
|
||||
opensPage,
|
||||
disabled = false,
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
}: ComposerTrackRowProps): ReactElement {
|
||||
const { selectItem } = useMenuContext("ComposerTrackRow");
|
||||
const { selectItem, openSub } = useMenuContext("ComposerTrackRow");
|
||||
const depth = useMenuDepth();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const handlePointerEnter = useCallback(() => setHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setHovered(false), []);
|
||||
const handleSelect = useCallback(
|
||||
() => selectItem(onPress, closeOnSelect),
|
||||
[closeOnSelect, onPress, selectItem],
|
||||
);
|
||||
const handleSelect = useCallback(() => {
|
||||
if (opensPage) {
|
||||
selectItem(() => openSub({ id: opensPage, depth }), false);
|
||||
return;
|
||||
}
|
||||
selectItem(onPress, closeOnSelect);
|
||||
}, [closeOnSelect, depth, onPress, openSub, opensPage, selectItem]);
|
||||
|
||||
const itemDataSet = useMemo(
|
||||
() => ({ menuItem: "true", menuDisabled: disabled ? "true" : "false" }),
|
||||
@@ -251,7 +267,7 @@ export function ComposerTrackRow({
|
||||
[hovered, renderRow],
|
||||
);
|
||||
|
||||
if (!onPress) {
|
||||
if (!onPress && !opensPage) {
|
||||
return <View accessibilityLabel={accessibilityLabel}>{renderRow(false)}</View>;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,11 @@ export const HEADER_INNER_HEIGHT_MOBILE = 56;
|
||||
export const WORKSPACE_SECONDARY_HEADER_HEIGHT = 36;
|
||||
export const HEADER_TOP_PADDING_MOBILE = 8;
|
||||
|
||||
export const COMPACT_FORM_FACTOR_WIDTH = 500;
|
||||
// Measured composer width; independent of the window's compact/wide breakpoint.
|
||||
export const COMPACT_CONTENT_WIDTH = 500;
|
||||
|
||||
// Settings uses the canonical desktop list + detail layout. Its sidebar and
|
||||
// detail target must fit together before it can share width with app navigation.
|
||||
// Settings navigation can shrink below its preferred width to preserve the detail
|
||||
// minimum. Both targets must fit before Settings shares width with app navigation.
|
||||
export const SETTINGS_DESKTOP_SIDEBAR_WIDTH = 320;
|
||||
export const SETTINGS_DESKTOP_DETAIL_MIN_WIDTH = 400;
|
||||
export const SETTINGS_DESKTOP_SPLIT_MIN_WIDTH =
|
||||
@@ -33,7 +34,7 @@ export {
|
||||
getIsElectronMac as getIsElectronRuntimeMac,
|
||||
} from "./platform";
|
||||
|
||||
/** Below 600 points, sidebars overlay the workspace. Medium and wider layouts dock them. */
|
||||
/** Below md (600 points), sidebars overlay the workspace; wider layouts can dock them. */
|
||||
export function isCompactBreakpoint(breakpoint: string | undefined): boolean {
|
||||
return breakpoint === "xs" || breakpoint === "sm";
|
||||
}
|
||||
|
||||
@@ -1914,7 +1914,6 @@ export const ar: TranslationResources = {
|
||||
parent: {
|
||||
backToParent: "العودة إلى الوكيل الأصلي",
|
||||
backTo: "العودة إلى {{label}}",
|
||||
parent: "الأصل",
|
||||
parentAgent: "الوكيل الأصلي",
|
||||
openParent: "فتح الوكيل الأصلي",
|
||||
},
|
||||
|
||||
@@ -1939,7 +1939,6 @@ export const en = {
|
||||
parent: {
|
||||
backToParent: "Back to parent",
|
||||
backTo: "Back to {{label}}",
|
||||
parent: "Parent",
|
||||
parentAgent: "Parent agent",
|
||||
openParent: "Open parent",
|
||||
},
|
||||
|
||||
@@ -1962,7 +1962,6 @@ export const es: TranslationResources = {
|
||||
parent: {
|
||||
backToParent: "Volver al padre",
|
||||
backTo: "Volver a {{label}}",
|
||||
parent: "Padre",
|
||||
parentAgent: "Agente padre",
|
||||
openParent: "Abrir padre",
|
||||
},
|
||||
|
||||
@@ -1960,7 +1960,6 @@ export const fr: TranslationResources = {
|
||||
parent: {
|
||||
backToParent: "Retour au parent",
|
||||
backTo: "Retour à {{label}}",
|
||||
parent: "Parent",
|
||||
parentAgent: "Agent parent",
|
||||
openParent: "Ouvrir le parent",
|
||||
},
|
||||
|
||||
@@ -1933,7 +1933,6 @@ export const ja: TranslationResources = {
|
||||
parent: {
|
||||
backToParent: "親に戻る",
|
||||
backTo: "{{label}} に戻る",
|
||||
parent: "親",
|
||||
parentAgent: "親エージェント",
|
||||
openParent: "親を開く",
|
||||
},
|
||||
|
||||
@@ -1924,7 +1924,6 @@ export const ko: TranslationResources = {
|
||||
parent: {
|
||||
backToParent: "상위 에이전트로 돌아가기",
|
||||
backTo: "{{label}}(으)로 돌아가기",
|
||||
parent: "상위",
|
||||
parentAgent: "상위 에이전트",
|
||||
openParent: "상위 에이전트 열기",
|
||||
},
|
||||
|
||||
@@ -1947,7 +1947,6 @@ export const ptBR: TranslationResources = {
|
||||
parent: {
|
||||
backToParent: "Voltar ao pai",
|
||||
backTo: "Voltar para {{label}}",
|
||||
parent: "Pai",
|
||||
parentAgent: "Agente pai",
|
||||
openParent: "Abrir pai",
|
||||
},
|
||||
|
||||
@@ -1946,7 +1946,6 @@ export const ru: TranslationResources = {
|
||||
parent: {
|
||||
backToParent: "Назад к родителю",
|
||||
backTo: "Назад к {{label}}",
|
||||
parent: "Родитель",
|
||||
parentAgent: "Родительский агент",
|
||||
openParent: "Открыть родителя",
|
||||
},
|
||||
|
||||
@@ -1892,7 +1892,6 @@ export const zhCN: TranslationResources = {
|
||||
parent: {
|
||||
backToParent: "返回父代理",
|
||||
backTo: "返回 {{label}}",
|
||||
parent: "父代理",
|
||||
parentAgent: "父代理",
|
||||
openParent: "打开父代理",
|
||||
},
|
||||
|
||||
@@ -44,7 +44,7 @@ import { useAgentDescriptor } from "@/agent-presentation";
|
||||
import { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
import { COMPACT_FORM_FACTOR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { COMPACT_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||
import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft";
|
||||
@@ -1512,7 +1512,7 @@ function ActiveAgentComposer({
|
||||
}) {
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
|
||||
COMPACT_FORM_FACTOR_WIDTH,
|
||||
COMPACT_CONTENT_WIDTH,
|
||||
{ initialIsBelow: isCompactFormFactor },
|
||||
);
|
||||
const paneContext = usePaneContext();
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type ArchiveFinishedStatus,
|
||||
useArchiveSubagent,
|
||||
useDetachSubagent,
|
||||
useSubagentOpensInSheet,
|
||||
useSubagentSheetTab,
|
||||
type SubagentRow,
|
||||
} from "@/subagents";
|
||||
import { ParentAgentPill } from "@/subagents/parent";
|
||||
@@ -100,7 +100,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
},
|
||||
[openSubagentTarget],
|
||||
);
|
||||
const opensInSheet = useSubagentOpensInSheet(workspaceKey);
|
||||
const sheetTabFor = useSubagentSheetTab(workspaceKey, tabId);
|
||||
const handleOpenChanges = useCallback(() => {
|
||||
if (!workspaceKey) {
|
||||
return;
|
||||
@@ -134,7 +134,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
rows={subagentRows}
|
||||
onOpenSubagent={handleOpenSubagent}
|
||||
onOpenProviderSubagent={handleOpenProviderSubagent}
|
||||
opensInSheet={opensInSheet}
|
||||
sheetTabFor={sheetTabFor}
|
||||
onArchiveSubagent={archiveSubagent}
|
||||
onArchiveFinished={onArchiveFinished}
|
||||
archiveFinishedStatus={archiveFinishedStatus}
|
||||
|
||||
@@ -16,9 +16,10 @@ 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 type { SheetTab } from "@/stores/workspace-layout-store";
|
||||
import { ParentAgentCallout } from "@/subagents/parent";
|
||||
import { type SubagentRow, useSubagentsForParent } from "@/subagents/select";
|
||||
import { useSubagentOpensInSheet } from "@/subagents/opens-in-sheet";
|
||||
import { useSubagentSheetTab } from "@/subagents/sheet-tab";
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
import {
|
||||
providerSubagentKey,
|
||||
@@ -51,12 +52,12 @@ function ProviderSubagentChildTrack({
|
||||
serverId,
|
||||
rows,
|
||||
onOpenProviderSubagent,
|
||||
opensInSheet,
|
||||
sheetTabFor,
|
||||
}: {
|
||||
serverId: string;
|
||||
rows: ReturnType<typeof useSubagentsForParent>;
|
||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
||||
opensInSheet: (row: SubagentRow) => boolean;
|
||||
sheetTabFor: (row: SubagentRow) => SheetTab | null;
|
||||
}) {
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
@@ -66,7 +67,7 @@ function ProviderSubagentChildTrack({
|
||||
rows={rows}
|
||||
onOpenSubagent={NOOP_SUBAGENT}
|
||||
onOpenProviderSubagent={onOpenProviderSubagent}
|
||||
opensInSheet={opensInSheet}
|
||||
sheetTabFor={sheetTabFor}
|
||||
onArchiveSubagent={NOOP_SUBAGENT}
|
||||
/>
|
||||
</ComposerTrackBar>
|
||||
@@ -116,7 +117,7 @@ function useProviderSubagentDescriptor(
|
||||
|
||||
function ProviderSubagentPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, workspaceId, target, openFileInWorkspace, openTab } = usePaneContext();
|
||||
const { serverId, workspaceId, tabId, 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)}`;
|
||||
@@ -144,8 +145,9 @@ function ProviderSubagentPanel() {
|
||||
providerParentSubagentId: target.subagentId,
|
||||
});
|
||||
const childTrackClearance = resolveChildTrackClearance(childRows.length, isCompact);
|
||||
const opensInSheet = useSubagentOpensInSheet(
|
||||
const sheetTabFor = useSubagentSheetTab(
|
||||
buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
||||
tabId,
|
||||
);
|
||||
const openProviderChild = useCallback(
|
||||
(parentAgentId: string, subagentId: string) => {
|
||||
@@ -274,7 +276,7 @@ function ProviderSubagentPanel() {
|
||||
serverId={serverId}
|
||||
rows={childRows}
|
||||
onOpenProviderSubagent={openProviderChild}
|
||||
opensInSheet={opensInSheet}
|
||||
sheetTabFor={sheetTabFor}
|
||||
/>
|
||||
</View>
|
||||
<ParentAgentCallout serverId={serverId} parentAgentId={target.parentAgentId} />
|
||||
|
||||
@@ -34,11 +34,24 @@ import {
|
||||
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
|
||||
import { render as renderEnvironment } from "@testing-library/react";
|
||||
import { useWorkspaceSheetEnvironment } from "@/screens/workspace/workspace-sheet-environment";
|
||||
import {
|
||||
SheetPaneHostProvider,
|
||||
useSheetPaneBridge,
|
||||
type SheetPaneHost,
|
||||
} from "@/screens/workspace/sheet-pane";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useAssistantFileLinkActions, type InlinePathTarget } from "@/assistant-file-links";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
const SHEET_PANE_HOST: SheetPaneHost = {
|
||||
workspaceKey: "host:workspace",
|
||||
serverId: "host",
|
||||
workspaceId: "workspace",
|
||||
isWorkspaceFocused: true,
|
||||
buildContent: () => {
|
||||
throw new Error("The environment probe builds no pane content");
|
||||
},
|
||||
};
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
beforeEach(() => {
|
||||
@@ -155,14 +168,21 @@ describe("workspace sheet environment", () => {
|
||||
}, [name]);
|
||||
return null;
|
||||
}
|
||||
function Capture() {
|
||||
bridge = useWorkspaceSheetEnvironment();
|
||||
function CaptureBridge() {
|
||||
bridge = useSheetPaneBridge();
|
||||
return (
|
||||
<ComposerEditingScope tabId="file" targetKey="file:a">
|
||||
<Probe name="pane" />
|
||||
</ComposerEditingScope>
|
||||
);
|
||||
}
|
||||
function Capture() {
|
||||
return (
|
||||
<SheetPaneHostProvider value={SHEET_PANE_HOST}>
|
||||
<CaptureBridge />
|
||||
</SheetPaneHostProvider>
|
||||
);
|
||||
}
|
||||
function Source({ workspaceRoot, active }: { workspaceRoot: string; active: boolean }) {
|
||||
const onOpenFile = useCallback(
|
||||
(target: InlinePathTarget) => {
|
||||
|
||||
@@ -122,7 +122,11 @@ import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-p
|
||||
import { HostVoicePage } from "@/screens/settings/voice/voice-page";
|
||||
import ProjectsScreen from "@/screens/projects-screen";
|
||||
import ProjectSettingsScreen from "@/screens/project-settings-screen";
|
||||
import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
SETTINGS_DESKTOP_DETAIL_MIN_WIDTH,
|
||||
SETTINGS_DESKTOP_SIDEBAR_WIDTH,
|
||||
useIsCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import { densityIconBox, densityIconSize } from "@/styles/density";
|
||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||
import {
|
||||
@@ -1544,12 +1548,14 @@ const desktopStyles = StyleSheet.create({
|
||||
},
|
||||
contentPane: {
|
||||
flex: 1,
|
||||
minWidth: SETTINGS_DESKTOP_DETAIL_MIN_WIDTH,
|
||||
},
|
||||
});
|
||||
|
||||
const sidebarStyles = StyleSheet.create((theme) => ({
|
||||
desktopContainer: {
|
||||
width: SETTINGS_DESKTOP_SIDEBAR_WIDTH,
|
||||
flexShrink: 1,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { createContext, useCallback, useContext, type ReactNode } from "react";
|
||||
import invariant from "tiny-invariant";
|
||||
import { NavigationContext, NavigationRouteContext } from "expo-router/react-navigation";
|
||||
import { useAssistantFileLinkEnvironmentBridge } from "@/assistant-file-links";
|
||||
import { useComposerEditingEnvironmentBridge } from "@/composer/editing-session";
|
||||
import { RetainedPanelActivity, useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { useDiffDocumentWorkspaceCacheBridge } from "@/git/diff-document/workspace-cache";
|
||||
import { HostRouteProvider, useHostRouteServerId } from "@/navigation/host-route-context";
|
||||
import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pane-content";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
/** What a sheet pane needs from the workspace it shows a tab of. */
|
||||
export interface SheetPaneHost {
|
||||
workspaceKey: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
isWorkspaceFocused: boolean;
|
||||
/** The pane content for a sheet's tab. Closing the tab from inside it runs `closeCurrentTab`. */
|
||||
buildContent: (
|
||||
tab: WorkspaceTabDescriptor,
|
||||
closeCurrentTab: () => void,
|
||||
) => WorkspacePaneContentModel;
|
||||
}
|
||||
|
||||
const SheetPaneHostContext = createContext<SheetPaneHost | null>(null);
|
||||
|
||||
/** `null` while there is no workspace to show sheets for. */
|
||||
export function SheetPaneHostProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: SheetPaneHost | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <SheetPaneHostContext.Provider value={value}>{children}</SheetPaneHostContext.Provider>;
|
||||
}
|
||||
|
||||
/** The host of the workspace the caller is in, or null while there is none. */
|
||||
export function useSheetPaneHostIfAny(): SheetPaneHost | null {
|
||||
return useContext(SheetPaneHostContext);
|
||||
}
|
||||
|
||||
export function useSheetPaneHost(): SheetPaneHost {
|
||||
const host = useSheetPaneHostIfAny();
|
||||
invariant(host, "SheetPaneHostProvider is required");
|
||||
return host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries a sheet pane's environment across the portal into the app overlay host: the workspace
|
||||
* host, and the environment of the built-in panels. Pane identity is supplied by the pane itself.
|
||||
*/
|
||||
export function useSheetPaneBridge(): ContextBridge {
|
||||
const host = useSheetPaneHost();
|
||||
const editing = useComposerEditingEnvironmentBridge();
|
||||
const cache = useDiffDocumentWorkspaceCacheBridge();
|
||||
const files = useAssistantFileLinkEnvironmentBridge();
|
||||
const active = useRetainedPanelActive();
|
||||
const serverId = useHostRouteServerId();
|
||||
const navigation = useContext(NavigationContext);
|
||||
const route = useContext(NavigationRouteContext);
|
||||
return useCallback(
|
||||
(children) => {
|
||||
const content = editing(cache(files(children)));
|
||||
return (
|
||||
<SheetPaneHostContext.Provider value={host}>
|
||||
<NavigationContext.Provider value={navigation}>
|
||||
<NavigationRouteContext.Provider value={route}>
|
||||
<RetainedPanelActivity active={active}>
|
||||
{serverId ? (
|
||||
<HostRouteProvider serverId={serverId}>{content}</HostRouteProvider>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</RetainedPanelActivity>
|
||||
</NavigationRouteContext.Provider>
|
||||
</NavigationContext.Provider>
|
||||
</SheetPaneHostContext.Provider>
|
||||
);
|
||||
},
|
||||
[host, editing, cache, files, active, serverId, navigation, route],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* A workspace tab shown in a sheet. One pane — header, body, bottom inset, focus — behind every
|
||||
* sheet container: the workspace's sheet tabs, and pages of a menu that shows a sheet in place.
|
||||
*
|
||||
* See docs/explorer-sidebar.md.
|
||||
*/
|
||||
|
||||
export {
|
||||
SheetPaneHostProvider,
|
||||
useSheetPaneBridge,
|
||||
useSheetPaneHost,
|
||||
useSheetPaneHostIfAny,
|
||||
type SheetPaneHost,
|
||||
} from "./host";
|
||||
export { SheetPane, useLeaveSheet, type SheetPaneProps } from "./sheet-pane";
|
||||
export { useSheetPanePage } from "./page";
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from "react";
|
||||
import { useMenuContext, type MenuFillPageDefinition } from "@/components/ui/menu";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { SheetTab } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { useSheetPaneBridge } from "./host";
|
||||
import { SheetPane } from "./sheet-pane";
|
||||
|
||||
/**
|
||||
* Shows sheet tabs as pages of a menu opened from a pane, so a row's sheet replaces the list it
|
||||
* was chosen from instead of stacking a second sheet over it. Back returns to the list; Close
|
||||
* closes the menu, which uncovers the pane the menu was opened from.
|
||||
*/
|
||||
export function useSheetPanePage(): (sheet: SheetTab) => MenuFillPageDefinition {
|
||||
const bridge = useSheetPaneBridge();
|
||||
const { target: opener } = usePaneContext();
|
||||
return useCallback(
|
||||
(sheet) => ({
|
||||
id: sheet.tab.tabId,
|
||||
fill: true,
|
||||
content: bridge(
|
||||
// Keyed like the detached container, so the panel's descriptor hook stays fixed per page.
|
||||
<MenuSheetPane
|
||||
key={`${sheet.tab.tabId}:${sheet.tab.target.kind}`}
|
||||
sheet={sheet}
|
||||
opener={opener}
|
||||
/>,
|
||||
),
|
||||
}),
|
||||
[bridge, opener],
|
||||
);
|
||||
}
|
||||
|
||||
function MenuSheetPane({ sheet, opener }: { sheet: SheetTab; opener: WorkspaceTabTarget }) {
|
||||
const { setOpen, goBack } = useMenuContext("MenuSheetPane");
|
||||
const close = useCallback(() => setOpen(false), [setOpen]);
|
||||
return <SheetPane sheet={sheet} onClose={close} onBack={goBack} opener={opener} isTop />;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
import { View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Maximize2 } from "lucide-react-native";
|
||||
import {
|
||||
SheetBottomInset,
|
||||
SheetDragArea,
|
||||
SheetHeaderIconButton,
|
||||
SheetHeaderView,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import type { PanelIconProps } from "@/panels/panel-registry";
|
||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import { WorkspacePaneContent } from "@/screens/workspace/workspace-pane-content";
|
||||
import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store";
|
||||
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { useSheetPaneHost } from "./host";
|
||||
|
||||
function PanelIcon({
|
||||
icon: Icon,
|
||||
size,
|
||||
color,
|
||||
}: { icon: ComponentType<PanelIconProps> } & PanelIconProps) {
|
||||
return <Icon size={size} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
export interface SheetPaneProps {
|
||||
sheet: SheetTab;
|
||||
/** Closes the sheet, everything it was chosen from included. */
|
||||
onClose: () => void;
|
||||
/** Steps back to the list the sheet was chosen from, when there is one. */
|
||||
onBack?: () => void;
|
||||
/** The pane closing the sheet uncovers, when the container knows it. */
|
||||
opener?: WorkspaceTabTarget | null;
|
||||
/** Only the top sheet's pane is focused; a covered one stays as live as the pane under it. */
|
||||
isTop: boolean;
|
||||
}
|
||||
|
||||
interface SheetPaneExit {
|
||||
close: () => void;
|
||||
opener: WorkspaceTabTarget | null;
|
||||
}
|
||||
|
||||
const SheetPaneExitContext = createContext<SheetPaneExit | null>(null);
|
||||
|
||||
/**
|
||||
* A tab shown in a sheet: its header (icon, title, Back, Open as tab, Close), which drags the
|
||||
* sheet, over its pane. Every sheet container renders this; only the container differs.
|
||||
*/
|
||||
export function SheetPane({ sheet, onClose, onBack, opener = null, isTop }: SheetPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const host = useSheetPaneHost();
|
||||
const { tab } = sheet;
|
||||
const registration = getPanelRegistration(tab.target.kind);
|
||||
if (!registration) {
|
||||
throw new Error(`No panel registration for kind: ${tab.target.kind}`);
|
||||
}
|
||||
const descriptor = registration.useDescriptor(tab.target as never, {
|
||||
serverId: host.serverId,
|
||||
workspaceId: host.workspaceId,
|
||||
tabId: tab.tabId,
|
||||
});
|
||||
|
||||
const openTabInFront = useWorkspaceLayoutStore((state) => state.openTabInFront);
|
||||
const { workspaceKey } = host;
|
||||
const handleOpenAsTab = useCallback(
|
||||
() => openTabInFront(workspaceKey, sheet),
|
||||
[openTabInFront, sheet, workspaceKey],
|
||||
);
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: descriptor.label,
|
||||
leading: <ThemedPanelIcon icon={descriptor.icon} size={16} />,
|
||||
back: onBack ? { onPress: onBack } : undefined,
|
||||
// The sheet is for looking; the full-screen glyph takes the tab out of the sheet and into
|
||||
// the switcher, where it gets its composer back.
|
||||
actions: sheet.policy.openAsTab ? (
|
||||
<SheetHeaderIconButton
|
||||
icon={Maximize2}
|
||||
accessibilityLabel={t("workspace.sheet.openAsTab")}
|
||||
onPress={handleOpenAsTab}
|
||||
testID="sheet-tab-open-as-tab"
|
||||
/>
|
||||
) : undefined,
|
||||
}),
|
||||
[descriptor.icon, descriptor.label, handleOpenAsTab, onBack, sheet.policy.openAsTab, t],
|
||||
);
|
||||
|
||||
const tabDescriptor = useMemo<WorkspaceTabDescriptor>(
|
||||
() => ({
|
||||
key: tab.tabId,
|
||||
tabId: tab.tabId,
|
||||
kind: tab.target.kind,
|
||||
target: tab.target,
|
||||
state: tab.state,
|
||||
}),
|
||||
[tab],
|
||||
);
|
||||
const { buildContent } = host;
|
||||
const content = useMemo(
|
||||
() => buildContent(tabDescriptor, onClose),
|
||||
[buildContent, onClose, tabDescriptor],
|
||||
);
|
||||
const exit = useMemo<SheetPaneExit>(() => ({ close: onClose, opener }), [onClose, opener]);
|
||||
const isPaneFocused = isTop && host.isWorkspaceFocused;
|
||||
useViewedSheetAgent({
|
||||
sourceId: `${workspaceKey}:sheet:${tab.tabId}`,
|
||||
serverId: host.serverId,
|
||||
agentId: isPaneFocused && tab.target.kind === "agent" ? tab.target.agentId : null,
|
||||
});
|
||||
|
||||
return (
|
||||
<SheetPaneExitContext.Provider value={exit}>
|
||||
<View style={styles.pane}>
|
||||
{/* The body scrolls on its own, so the header is where the sheet drags from. */}
|
||||
<SheetDragArea>
|
||||
<SheetHeaderView header={header} onClose={onClose} testID="sheet-tab-header" />
|
||||
</SheetDragArea>
|
||||
<SheetBottomInset
|
||||
style={styles.body}
|
||||
role="dialog"
|
||||
aria-label={descriptor.label}
|
||||
testID="sheet-tab-body"
|
||||
>
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={host.isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</SheetBottomInset>
|
||||
</View>
|
||||
</SheetPaneExitContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent of the sheet on top is viewed, alongside the pane under it: its timeline catches up
|
||||
* and stays live. The workspace only publishes the agents of its panes.
|
||||
*/
|
||||
function useViewedSheetAgent({
|
||||
sourceId,
|
||||
serverId,
|
||||
agentId,
|
||||
}: {
|
||||
sourceId: string;
|
||||
serverId: string;
|
||||
agentId: string | null;
|
||||
}) {
|
||||
const viewedTimelineSync = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.viewedTimelineSync ?? null,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!agentId) return;
|
||||
void getHostRuntimeStore()
|
||||
.prepareAgentTimeline(serverId, agentId)
|
||||
.catch(() => undefined);
|
||||
}, [agentId, serverId]);
|
||||
useLayoutEffect(() => {
|
||||
if (!agentId || !viewedTimelineSync) return;
|
||||
viewedTimelineSync.replaceVisibleAgentIds(sourceId, [agentId]);
|
||||
return () => viewedTimelineSync.replaceVisibleAgentIds(sourceId, []);
|
||||
}, [agentId, sourceId, viewedTimelineSync]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves the sheet the calling pane is shown in, if any, on the way to `destination`. Answers
|
||||
* whether closing the sheet already lands there, so the caller navigates only when it does not.
|
||||
*/
|
||||
export function useLeaveSheet(): (destination: WorkspaceTabTarget) => boolean {
|
||||
const exit = useContext(SheetPaneExitContext);
|
||||
return useCallback(
|
||||
(destination) => {
|
||||
if (!exit) return false;
|
||||
exit.close();
|
||||
return exit.opener !== null && workspaceTabTargetsEqual(exit.opener, destination);
|
||||
},
|
||||
[exit],
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pane: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
});
|
||||
@@ -1,131 +1,62 @@
|
||||
import type { BottomSheetHandleProps } from "@gorhom/bottom-sheet";
|
||||
import { useIsFocused } from "expo-router/react-navigation";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
type ComponentType,
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useStore } from "zustand";
|
||||
import { createStore } from "zustand/vanilla";
|
||||
import { Maximize2 } from "lucide-react-native";
|
||||
import {
|
||||
FULL_SHEET_SNAP_POINTS,
|
||||
SheetBackground,
|
||||
SheetHandle,
|
||||
SheetHeaderIconButton,
|
||||
SheetHeaderView,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
type IsolatedBottomSheetModalRef,
|
||||
systemBackPress,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import type { PanelIconProps } from "@/panels/panel-registry";
|
||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
WorkspacePaneContent,
|
||||
type WorkspacePaneContentModel,
|
||||
} from "@/screens/workspace/workspace-pane-content";
|
||||
SheetPane,
|
||||
useSheetPaneBridge,
|
||||
useSheetPaneHost,
|
||||
useSheetPaneHostIfAny,
|
||||
} from "@/screens/workspace/sheet-pane";
|
||||
import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store";
|
||||
import { useWorkspaceSheetEnvironment } from "./workspace-sheet-environment";
|
||||
|
||||
const SNAP_POINTS = ["92%"];
|
||||
const NO_SHEETS: readonly SheetTab[] = [];
|
||||
|
||||
function PanelIcon({
|
||||
icon: Icon,
|
||||
size,
|
||||
color,
|
||||
}: { icon: ComponentType<PanelIconProps> } & PanelIconProps) {
|
||||
return <Icon size={size} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
interface SheetTabHandleValue {
|
||||
header: SheetHeader;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sheet's handle, showing its header. Gorhom takes `handleComponent` as a component type and
|
||||
* renders it on the far side of the portal: a new function per render would remount the header on
|
||||
* every change, and no context from here reaches it. So each sheet gets one handle component for
|
||||
* its lifetime, which reads the header through a store the sheet keeps current.
|
||||
*/
|
||||
function useSheetTabHandle(value: SheetTabHandleValue): FC<BottomSheetHandleProps> {
|
||||
const [store] = useState(() => createStore<SheetTabHandleValue>(() => value));
|
||||
useEffect(() => store.setState(value, true), [store, value]);
|
||||
const [Handle] = useState(
|
||||
() =>
|
||||
function SheetTabHandle(props: BottomSheetHandleProps) {
|
||||
const { header, onClose } = useStore(store);
|
||||
return (
|
||||
<SheetHandle {...props}>
|
||||
<SheetHeaderView header={header} onClose={onClose} testID="sheet-tab-header" />
|
||||
</SheetHandle>
|
||||
);
|
||||
},
|
||||
);
|
||||
return Handle;
|
||||
}
|
||||
|
||||
export interface SheetTabsProps {
|
||||
workspaceKey: string | null;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
isWorkspaceFocused: boolean;
|
||||
buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel;
|
||||
/** The workspace's ordinary tab close, including its unsaved-changes guard. */
|
||||
onCloseTab: (tabId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface SheetTabSheetProps extends Omit<SheetTabsProps, "workspaceKey" | "onCloseTab"> {
|
||||
interface SheetTabSheetProps {
|
||||
sheet: SheetTab;
|
||||
/** Only the top sheet's pane is focused and answers Android Back; the ones below wait their turn. */
|
||||
/** Only the top sheet answers Android Back; the ones below wait their turn. */
|
||||
isTop: boolean;
|
||||
/** Runs the guarded close and resolves to whether the tab is still open. */
|
||||
onClose: (tabId: string) => Promise<boolean>;
|
||||
onOpenAsTab: (tabId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace's sheet tabs, one sheet each, on the app-wide sheet stack. Their environment
|
||||
* crosses into the app overlay host so compact sheets cover both sidebars, and a
|
||||
* sheet opened from any other sheet (a subagent chosen from the subagents list, say) lands on top of
|
||||
* it and uncovers it on close. The shared sheet parts (`SheetBackground`, `SheetHandle`,
|
||||
* `SheetHeaderView`) keep them looking and dragging like every other sheet.
|
||||
* crosses into the app overlay host so compact sheets cover both sidebars, and a sheet opened
|
||||
* from another one lands on top of it and uncovers it on close.
|
||||
*/
|
||||
export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps) {
|
||||
const sheets = useWorkspaceLayoutStore((state) =>
|
||||
workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS,
|
||||
);
|
||||
const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab);
|
||||
const handleOpenAsTab = useCallback(
|
||||
(tabId: string) => {
|
||||
if (workspaceKey) openSheetAsTab(workspaceKey, tabId);
|
||||
},
|
||||
[openSheetAsTab, workspaceKey],
|
||||
export function SheetTabs({ onCloseTab }: SheetTabsProps) {
|
||||
const host = useSheetPaneHostIfAny();
|
||||
return host ? (
|
||||
<WorkspaceSheetTabs workspaceKey={host.workspaceKey} onCloseTab={onCloseTab} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
function WorkspaceSheetTabs({
|
||||
workspaceKey,
|
||||
onCloseTab,
|
||||
}: SheetTabsProps & { workspaceKey: string }) {
|
||||
const sheets = useWorkspaceLayoutStore(
|
||||
(state) => state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS,
|
||||
);
|
||||
const handleClose = useCallback(
|
||||
async (tabId: string) => {
|
||||
await onCloseTab(tabId);
|
||||
// A declined unsaved-changes prompt leaves the tab in its sheet.
|
||||
const open = workspaceKey
|
||||
? useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey]
|
||||
: undefined;
|
||||
const open = useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey];
|
||||
return open?.some((sheet) => sheet.tab.tabId === tabId) ?? false;
|
||||
},
|
||||
[onCloseTab, workspaceKey],
|
||||
@@ -136,42 +67,21 @@ export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps)
|
||||
<SheetTabSheet
|
||||
// Keyed by tab id and kind, so the registration and its descriptor hook stay fixed.
|
||||
key={`${sheet.tab.tabId}:${sheet.tab.target.kind}`}
|
||||
{...rest}
|
||||
sheet={sheet}
|
||||
isTop={index === sheets.length - 1}
|
||||
onClose={handleClose}
|
||||
onOpenAsTab={handleOpenAsTab}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTabSheet({
|
||||
sheet,
|
||||
isTop,
|
||||
serverId,
|
||||
workspaceId,
|
||||
isWorkspaceFocused,
|
||||
buildContent,
|
||||
onClose,
|
||||
onOpenAsTab,
|
||||
}: SheetTabSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
function SheetTabSheet({ sheet, isTop, onClose }: SheetTabSheetProps) {
|
||||
const { tab } = sheet;
|
||||
const sheetRef = useRef<IsolatedBottomSheetModalRef>(null);
|
||||
const isScreenFocused = useIsFocused();
|
||||
const contextBridge = useWorkspaceSheetEnvironment();
|
||||
ensurePanelsRegistered();
|
||||
const registration = getPanelRegistration(tab.target.kind);
|
||||
if (!registration) {
|
||||
throw new Error(`No panel registration for kind: ${tab.target.kind}`);
|
||||
}
|
||||
const descriptor = registration.useDescriptor(tab.target as never, {
|
||||
serverId,
|
||||
workspaceId,
|
||||
tabId: tab.tabId,
|
||||
});
|
||||
const { isWorkspaceFocused } = useSheetPaneHost();
|
||||
const contextBridge = useSheetPaneBridge();
|
||||
|
||||
// The sheet lives as long as its store entry: it presents on mount, and unmounting takes it off
|
||||
// the stack, which uncovers the sheet below. Gorhom slides an unmounting sheet down and still
|
||||
@@ -209,105 +119,23 @@ function SheetTabSheet({
|
||||
});
|
||||
}, [close, ownsBackPress]);
|
||||
|
||||
const handleOpenAsTab = useCallback(() => onOpenAsTab(tab.tabId), [onOpenAsTab, tab.tabId]);
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: descriptor.label,
|
||||
leading: <ThemedPanelIcon icon={descriptor.icon} size={16} />,
|
||||
// The sheet is for looking; the full-screen glyph takes the tab out of the sheet and into
|
||||
// the switcher, where it gets its composer back.
|
||||
actions: sheet.policy.openAsTab ? (
|
||||
<SheetHeaderIconButton
|
||||
icon={Maximize2}
|
||||
accessibilityLabel={t("workspace.sheet.openAsTab")}
|
||||
onPress={handleOpenAsTab}
|
||||
testID="sheet-tab-open-as-tab"
|
||||
/>
|
||||
) : undefined,
|
||||
}),
|
||||
[descriptor.icon, descriptor.label, handleOpenAsTab, sheet.policy.openAsTab, t],
|
||||
);
|
||||
const handleValue = useMemo<SheetTabHandleValue>(
|
||||
() => ({ header, onClose: close }),
|
||||
[close, header],
|
||||
);
|
||||
// The header is the sheet's handle: the panel scrolls on its own, so dragging the content would
|
||||
// fight it, and the header is where people reach to pull the sheet down.
|
||||
const SheetTabHandle = useSheetTabHandle(handleValue);
|
||||
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={contextBridge}
|
||||
snapPoints={SNAP_POINTS}
|
||||
snapPoints={FULL_SHEET_SNAP_POINTS}
|
||||
enableDynamicSizing={false}
|
||||
enablePanDownToClose
|
||||
// The pane scrolls on its own; the sheet drags from its handle and header.
|
||||
enableContentPanningGesture={false}
|
||||
enableDismissOnClose={false}
|
||||
backgroundComponent={SheetBackground}
|
||||
handleComponent={SheetTabHandle}
|
||||
handleComponent={SheetHandle}
|
||||
backdropOpacity={0.45}
|
||||
onChange={handleChange}
|
||||
accessible={false}
|
||||
>
|
||||
{/* A covered sheet stays as live as the pane under it; only focus follows the top sheet. */}
|
||||
<SheetTabBody
|
||||
tab={tab}
|
||||
label={descriptor.label}
|
||||
isPaneFocused={isTop && isWorkspaceFocused}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
buildContent={buildContent}
|
||||
/>
|
||||
<SheetPane sheet={sheet} onClose={close} isTop={isTop} />
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTabBody({
|
||||
tab,
|
||||
label,
|
||||
isPaneFocused,
|
||||
isWorkspaceFocused,
|
||||
buildContent,
|
||||
}: {
|
||||
tab: SheetTab["tab"];
|
||||
label: string;
|
||||
/** Only the top sheet's pane is focused; the ones below are covered. */
|
||||
isPaneFocused: boolean;
|
||||
isWorkspaceFocused: boolean;
|
||||
buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel;
|
||||
}) {
|
||||
const tabDescriptor = useMemo<WorkspaceTabDescriptor>(
|
||||
() => ({
|
||||
key: tab.tabId,
|
||||
tabId: tab.tabId,
|
||||
kind: tab.target.kind,
|
||||
target: tab.target,
|
||||
state: tab.state,
|
||||
}),
|
||||
[tab],
|
||||
);
|
||||
const content = useMemo(() => buildContent(tabDescriptor), [buildContent, tabDescriptor]);
|
||||
// The sheet reaches the bottom of the screen, so it owns the system navigation inset.
|
||||
const { bottom: bottomInset } = useSafeAreaInsets();
|
||||
const contentStyle = useMemo(
|
||||
() => [styles.content, { paddingBottom: bottomInset }],
|
||||
[bottomInset],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={contentStyle} role="dialog" aria-label={label} testID="sheet-tab-body">
|
||||
<WorkspacePaneContent
|
||||
content={content}
|
||||
isWorkspaceFocused={isWorkspaceFocused}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -111,28 +111,6 @@ test("pane retargeting replaces the viewed agent and duplicate panes collapse to
|
||||
}).toEqual({ duplicate: ["agent-a"], retargeted: ["agent-a", "agent-b"] });
|
||||
});
|
||||
|
||||
test("the agent shown in the workspace sheet is viewed alongside the pane under it", () => {
|
||||
const layout: WorkspaceLayout = {
|
||||
focusedPaneId: "main",
|
||||
root: { kind: "pane", pane: { id: "main", tabIds: ["a"], focusedTabId: "a" } },
|
||||
};
|
||||
const tabs: WorkspaceTab[] = [
|
||||
{ tabId: "a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 },
|
||||
];
|
||||
const sheetTab: WorkspaceTab = {
|
||||
tabId: "sheet",
|
||||
target: { kind: "agent", agentId: "agent-child" },
|
||||
createdAt: 2,
|
||||
};
|
||||
|
||||
expect(
|
||||
selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: true, focusedPaneOnly: true }),
|
||||
).toEqual(["agent-a", "agent-child"]);
|
||||
expect(
|
||||
selectVisibleAgentIds({ layout, tabs, sheetTab, routeFocused: false, focusedPaneOnly: true }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("single-pane tablet includes its visible Explorer agent without including other splits", () => {
|
||||
const layout: WorkspaceLayout = {
|
||||
focusedPaneId: "main",
|
||||
|
||||
@@ -6,8 +6,6 @@ import { deriveWorkspacePaneState } from "./workspace-pane-state";
|
||||
export function selectVisibleAgentIds(input: {
|
||||
layout: WorkspaceLayout | null;
|
||||
tabs: WorkspaceTab[];
|
||||
/** The workspace sheet's tab, shown over the panes. */
|
||||
sheetTab?: WorkspaceTab | null;
|
||||
routeFocused: boolean;
|
||||
focusedPaneOnly: boolean;
|
||||
explorerPaneId?: string | null;
|
||||
@@ -29,7 +27,6 @@ export function selectVisibleAgentIds(input: {
|
||||
.target;
|
||||
return target ? [target] : [];
|
||||
});
|
||||
if (input.sheetTab) targets.push(input.sheetTab.target);
|
||||
return [
|
||||
...new Set(targets.flatMap((target) => (target.kind === "agent" ? [target.agentId] : []))),
|
||||
].sort();
|
||||
@@ -38,7 +35,6 @@ export function selectVisibleAgentIds(input: {
|
||||
export function useVisibleAgentIds(input: {
|
||||
layout: WorkspaceLayout | null;
|
||||
tabs: WorkspaceTab[];
|
||||
sheetTab: WorkspaceTab | null;
|
||||
routeFocused: boolean;
|
||||
focusedPaneOnly: boolean;
|
||||
explorerPaneId?: string | null;
|
||||
|
||||
@@ -11,18 +11,7 @@ import {
|
||||
} from "@/screens/workspace/workspace-pane-content";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import { usePaneContext, usePaneFocus, type PaneContextValue } from "@/panels/pane-context";
|
||||
|
||||
vi.mock("@/panels/register-panels", () => ({
|
||||
ensurePanelsRegistered: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/panels/panel-registry", () => ({
|
||||
getPanelRegistration: () => ({
|
||||
kind: "agent",
|
||||
component: ProbePanel,
|
||||
useDescriptor: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
import { definePanel, registerPanel } from "@/panels/panel-registry";
|
||||
|
||||
interface ProbeSnapshot {
|
||||
paneContextValue: PaneContextValue;
|
||||
@@ -48,6 +37,18 @@ function ProbePanel() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The workspace screen registers the real panels; this probe stands in for each kind the tests open.
|
||||
for (const kind of ["agent", "draft"] as const) {
|
||||
registerPanel(
|
||||
definePanel(kind, {
|
||||
component: ProbePanel,
|
||||
useDescriptor: () => {
|
||||
throw new Error("The probe panel has no descriptor");
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const agentTab: WorkspaceTabDescriptor = {
|
||||
key: "agent_agent-a",
|
||||
tabId: "agent_agent-a",
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "@/panels/pane-context";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { getPanelRegistration } from "@/panels/panel-registry";
|
||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
@@ -56,7 +55,6 @@ export function buildWorkspacePaneContentModel({
|
||||
onOpenWorkspaceFile,
|
||||
onOpenImportSheet,
|
||||
}: BuildWorkspacePaneContentModelInput): WorkspacePaneContentModel {
|
||||
ensurePanelsRegistered();
|
||||
const registration = getPanelRegistration(tab.kind);
|
||||
invariant(registration, `No panel registration for kind: ${tab.kind}`);
|
||||
return {
|
||||
|
||||
@@ -180,6 +180,8 @@ import {
|
||||
type WorkspacePaneContentModel,
|
||||
} from "@/screens/workspace/workspace-pane-content";
|
||||
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import { SheetPaneHostProvider, type SheetPaneHost } from "@/screens/workspace/sheet-pane";
|
||||
import { SheetTabs } from "@/screens/workspace/sheet-tabs";
|
||||
import type { PaneHost } from "@/panels/panel-manifest";
|
||||
import { WorkspaceFocusProvider } from "@/workspace/focus";
|
||||
@@ -421,6 +423,10 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
|
||||
workspaceId,
|
||||
isRouteFocused,
|
||||
}: WorkspaceScreenProps) {
|
||||
// Every reader of the panel registry renders inside the workspace screen, so it registers the
|
||||
// panels before any of them renders. A reader importing the registration instead would import
|
||||
// every panel, and the panels render readers.
|
||||
ensurePanelsRegistered();
|
||||
const navigationFocused = useIsFocused();
|
||||
useEffect(() => {
|
||||
traceInstant("paseo.workspace.mount", { serverId, workspaceId });
|
||||
@@ -764,13 +770,17 @@ function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) {
|
||||
function WorkspaceContentProviders({
|
||||
children,
|
||||
workspaceKey,
|
||||
sheetPaneHost,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
workspaceKey: string | null;
|
||||
sheetPaneHost: SheetPaneHost | null;
|
||||
}) {
|
||||
return (
|
||||
<WorkspaceFocusProvider workspaceKey={workspaceKey}>
|
||||
<DiffDocumentWorkspaceCacheProvider>{children}</DiffDocumentWorkspaceCacheProvider>
|
||||
<DiffDocumentWorkspaceCacheProvider>
|
||||
<SheetPaneHostProvider value={sheetPaneHost}>{children}</SheetPaneHostProvider>
|
||||
</DiffDocumentWorkspaceCacheProvider>
|
||||
</WorkspaceFocusProvider>
|
||||
);
|
||||
}
|
||||
@@ -1342,10 +1352,6 @@ function WorkspaceScreenContent({
|
||||
const sheets = useWorkspaceLayoutStore((state) =>
|
||||
persistenceKey ? (state.sheetsByWorkspace[persistenceKey] ?? NO_SHEETS) : NO_SHEETS,
|
||||
);
|
||||
// Only the top sheet is on screen.
|
||||
const sheetTab = useWorkspaceLayoutStore((state) =>
|
||||
persistenceKey ? (state.sheetsByWorkspace[persistenceKey]?.at(-1)?.tab ?? null) : null,
|
||||
);
|
||||
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
|
||||
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
|
||||
const setWorkspaceTabState = useWorkspaceLayoutStore((state) => state.setTabState);
|
||||
@@ -1417,7 +1423,6 @@ function WorkspaceScreenContent({
|
||||
const visibleAgentIds = useVisibleAgentIds({
|
||||
layout: workspaceLayout,
|
||||
tabs: uiTabs,
|
||||
sheetTab,
|
||||
routeFocused: isRouteFocused,
|
||||
focusedPaneOnly: syncFocusedPaneOnly,
|
||||
explorerPaneId: explorerSidebarPaneId,
|
||||
@@ -2916,6 +2921,8 @@ function WorkspaceScreenContent({
|
||||
paneId?: string | null;
|
||||
focusPaneBeforeOpen?: boolean;
|
||||
host?: PaneHost;
|
||||
/** Overrides the guarded tab close, for a host that closes the tab its own way. */
|
||||
onCloseCurrentTab?: () => void;
|
||||
}) =>
|
||||
buildWorkspacePaneContentModel({
|
||||
tab: input.tab,
|
||||
@@ -2965,9 +2972,11 @@ function WorkspaceScreenContent({
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
onCloseCurrentTab: () => {
|
||||
void handleCloseTabById(input.tab.tabId);
|
||||
},
|
||||
onCloseCurrentTab:
|
||||
input.onCloseCurrentTab ??
|
||||
(() => {
|
||||
void handleCloseTabById(input.tab.tabId);
|
||||
}),
|
||||
onRetargetCurrentTab: (target) => {
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
@@ -3042,16 +3051,36 @@ function WorkspaceScreenContent({
|
||||
[buildPaneContentModel],
|
||||
);
|
||||
const buildSheetPaneContentModel = useCallback(
|
||||
function buildSheetPaneContentModel(tab: WorkspaceTabDescriptor) {
|
||||
function buildSheetPaneContentModel(tab: WorkspaceTabDescriptor, closeCurrentTab: () => void) {
|
||||
return buildPaneContentModel({
|
||||
tab,
|
||||
paneId: null,
|
||||
focusPaneBeforeOpen: false,
|
||||
host: "sheet",
|
||||
onCloseCurrentTab: closeCurrentTab,
|
||||
});
|
||||
},
|
||||
[buildPaneContentModel],
|
||||
);
|
||||
const sheetPaneHost = useMemo<SheetPaneHost | null>(
|
||||
() =>
|
||||
persistenceKey
|
||||
? {
|
||||
workspaceKey: persistenceKey,
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
isWorkspaceFocused: isRouteFocused,
|
||||
buildContent: buildSheetPaneContentModel,
|
||||
}
|
||||
: null,
|
||||
[
|
||||
buildSheetPaneContentModel,
|
||||
isRouteFocused,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
persistenceKey,
|
||||
],
|
||||
);
|
||||
const handleFocusPane = useStableEvent(function handleFocusPane(paneId: string) {
|
||||
if (!persistenceKey || paneFocusSuppressedRef.current) {
|
||||
return;
|
||||
@@ -3540,14 +3569,7 @@ function WorkspaceScreenContent({
|
||||
</StreamTopOverlayProvider>
|
||||
)}
|
||||
</FloatingHeaderLayout>
|
||||
<SheetTabs
|
||||
workspaceKey={persistenceKey}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
isWorkspaceFocused={isRouteFocused}
|
||||
buildContent={buildSheetPaneContentModel}
|
||||
onCloseTab={handleCloseTabById}
|
||||
/>
|
||||
<SheetTabs onCloseTab={handleCloseTabById} />
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.centerColumn}>
|
||||
@@ -3767,7 +3789,11 @@ function WorkspaceScreenContent({
|
||||
return gatedWorkspaceScreen;
|
||||
}
|
||||
return (
|
||||
<WorkspaceContentProviders key={persistenceKey} workspaceKey={persistenceKey}>
|
||||
<WorkspaceContentProviders
|
||||
key={persistenceKey}
|
||||
workspaceKey={persistenceKey}
|
||||
sheetPaneHost={sheetPaneHost}
|
||||
>
|
||||
<AssistantFileLinkResolverProvider
|
||||
client={client}
|
||||
serverId={normalizedServerId}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import React, { useCallback, useContext } from "react";
|
||||
import { NavigationContext, NavigationRouteContext } from "expo-router/react-navigation";
|
||||
import { useAssistantFileLinkEnvironmentBridge } from "@/assistant-file-links";
|
||||
import { useComposerEditingEnvironmentBridge } from "@/composer/editing-session";
|
||||
import { RetainedPanelActivity, useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { useDiffDocumentWorkspaceCacheBridge } from "@/git/diff-document/workspace-cache";
|
||||
import { HostRouteProvider, useHostRouteServerId } from "@/navigation/host-route-context";
|
||||
|
||||
/** The environment of the built-in compact panels; pane identity is supplied by their body. */
|
||||
export function useWorkspaceSheetEnvironment(): ContextBridge {
|
||||
const editing = useComposerEditingEnvironmentBridge();
|
||||
const cache = useDiffDocumentWorkspaceCacheBridge();
|
||||
const files = useAssistantFileLinkEnvironmentBridge();
|
||||
const active = useRetainedPanelActive();
|
||||
const serverId = useHostRouteServerId();
|
||||
const navigation = useContext(NavigationContext);
|
||||
const route = useContext(NavigationRouteContext);
|
||||
return useCallback(
|
||||
(children) => {
|
||||
const content = editing(cache(files(children)));
|
||||
return (
|
||||
<NavigationContext.Provider value={navigation}>
|
||||
<NavigationRouteContext.Provider value={route}>
|
||||
<RetainedPanelActivity active={active}>
|
||||
{serverId ? (
|
||||
<HostRouteProvider serverId={serverId}>{content}</HostRouteProvider>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</RetainedPanelActivity>
|
||||
</NavigationRouteContext.Provider>
|
||||
</NavigationContext.Provider>
|
||||
);
|
||||
},
|
||||
[editing, cache, files, active, serverId, navigation, route],
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { Check } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import invariant from "tiny-invariant";
|
||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import { getPanelRegistration, type PanelIconProps } from "@/panels/panel-registry";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
@@ -43,7 +42,6 @@ export function WorkspaceTabPresentationResolver({
|
||||
workspaceId,
|
||||
children,
|
||||
}: WorkspaceTabPresentationResolverProps): ReactElement {
|
||||
ensurePanelsRegistered();
|
||||
const registration = getPanelRegistration(tab.kind);
|
||||
invariant(registration, `No panel registration for kind: ${tab.kind}`);
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
normalizeLayout,
|
||||
removePaneFromTree,
|
||||
removeTabFromTree,
|
||||
selectSheetTab,
|
||||
stripEphemeralTabsFromLayout,
|
||||
type SplitNode,
|
||||
type SplitPane,
|
||||
@@ -4496,6 +4497,12 @@ describe("compact workspace sheet", () => {
|
||||
return store.getState().sheetsByWorkspace[workspaceKey]?.at(-1);
|
||||
}
|
||||
|
||||
function requireTopSheet(store: ReturnType<typeof createCompactStore>) {
|
||||
const sheet = topSheet(store);
|
||||
if (!sheet) throw new Error("Expected a sheet");
|
||||
return sheet;
|
||||
}
|
||||
|
||||
function sheetTargets(store: ReturnType<typeof createCompactStore>) {
|
||||
return (store.getState().sheetsByWorkspace[workspaceKey] ?? []).map(
|
||||
(sheet) => sheet.tab.target,
|
||||
@@ -4647,7 +4654,7 @@ describe("compact workspace sheet", () => {
|
||||
.getState()
|
||||
.openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" });
|
||||
|
||||
store.getState().openSheetAsTab(workspaceKey, nextTabId ?? "");
|
||||
store.getState().openTabInFront(workspaceKey, requireTopSheet(store));
|
||||
|
||||
expect(sheetTargets(store)).toEqual([]);
|
||||
expect(tabKinds(store)).toEqual(["terminal", "file"]);
|
||||
@@ -4683,7 +4690,7 @@ describe("compact workspace sheet", () => {
|
||||
if (!sheetTabId) throw new Error("Expected a sheet tab");
|
||||
store.getState().setTabState(workspaceKey, sheetTabId, { scrollTop: 120 });
|
||||
|
||||
const tabId = store.getState().openSheetAsTab(workspaceKey, sheetTabId);
|
||||
const tabId = store.getState().openTabInFront(workspaceKey, requireTopSheet(store));
|
||||
|
||||
expect(tabId).toBe(sheetTabId);
|
||||
expect(topSheet(store)).toBeUndefined();
|
||||
@@ -4787,6 +4794,40 @@ describe("compact workspace sheet", () => {
|
||||
expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]);
|
||||
});
|
||||
|
||||
it("describes the sheet a reveal would show without opening it, and opens it as a tab", () => {
|
||||
seedAgents([
|
||||
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
|
||||
]);
|
||||
const store = createCompactStore();
|
||||
const rootTabId = store
|
||||
.getState()
|
||||
.openTab({ workspaceKey, target: { kind: "agent", agentId: "root" }, intent: "reveal" });
|
||||
|
||||
const sheet = selectSheetTab(store.getState(), {
|
||||
workspaceKey,
|
||||
target: { kind: "agent", agentId: "child" },
|
||||
parentTabId: rootTabId ?? undefined,
|
||||
});
|
||||
expect(sheet?.parentTabId).toBe(rootTabId);
|
||||
expect(
|
||||
selectSheetTab(store.getState(), {
|
||||
workspaceKey,
|
||||
target: { kind: "agent", agentId: "root" },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(topSheet(store)).toBeUndefined();
|
||||
if (!sheet) throw new Error("Expected a sheet tab");
|
||||
|
||||
const tabId = store.getState().openTabInFront(workspaceKey, sheet);
|
||||
|
||||
expect(tabId).toBe(sheet.tab.tabId);
|
||||
expect(topSheet(store)).toBeUndefined();
|
||||
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(tabId);
|
||||
expect(layout.parentTabIdByTabId?.[tabId ?? ""]).toBe(rootTabId);
|
||||
});
|
||||
|
||||
it("gives a sheet target opened from inside the sheet the sheet's own parent", () => {
|
||||
seedAgents([
|
||||
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||
@@ -4811,7 +4852,7 @@ describe("compact workspace sheet", () => {
|
||||
});
|
||||
expect(topSheet(store)?.parentTabId).toBe(rootTabId);
|
||||
|
||||
store.getState().openSheetAsTab(workspaceKey, fileSheetTabId ?? "");
|
||||
store.getState().openTabInFront(workspaceKey, requireTopSheet(store));
|
||||
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||
expect(layout.parentTabIdByTabId?.[fileSheetTabId ?? ""]).toBe(rootTabId);
|
||||
});
|
||||
|
||||
@@ -156,13 +156,12 @@ interface WorkspaceLayoutStore {
|
||||
* `compactSheet` opens in a sheet; the returned tab id is the sheet tab's id.
|
||||
*/
|
||||
openTab: (input: OpenWorkspaceTabInput) => string | null;
|
||||
/** Whether `openTab` with this input would show the target in a sheet. */
|
||||
opensInSheet: (
|
||||
input: Pick<OpenWorkspaceTabInput, "workspaceKey" | "target" | "intent">,
|
||||
) => boolean;
|
||||
setLayoutMode: (mode: WorkspaceLayoutMode) => void;
|
||||
/** Moves a sheet's tab, keeping its tab id, into the focused pane. */
|
||||
openSheetAsTab: (workspaceKey: string, tabId: string) => string | null;
|
||||
/**
|
||||
* Moves a sheet's tab, keeping its tab id, into the focused pane and dismisses the sheets that
|
||||
* would cover it. The sheet need not be one of the workspace's sheet tabs.
|
||||
*/
|
||||
openTabInFront: (workspaceKey: string, sheet: SheetTab) => string | null;
|
||||
/** Placement resolves lazily so an already acknowledged workspace never creates a side pane. */
|
||||
autoOpenPullRequestTab: (
|
||||
workspaceKey: string,
|
||||
@@ -521,6 +520,29 @@ export function selectExplorerSidebarPaneId(
|
||||
return resolveExplorerSidebarPaneId(layout, state.explorerSidebarPaneIdByWorkspace[workspaceKey]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The sheet tab a reveal of this target would show, or null when it opens as a tab. A caller
|
||||
* that hosts the sheet itself (a list showing it in place) renders this without opening it.
|
||||
*/
|
||||
export function selectSheetTab(
|
||||
state: Pick<WorkspaceLayoutStore, "layoutMode" | "layoutByWorkspace" | "sheetsByWorkspace">,
|
||||
input: Pick<OpenWorkspaceTabInput, "workspaceKey" | "target" | "parentTabId">,
|
||||
): SheetTab | null {
|
||||
const workspaceKey = trimNonEmpty(input.workspaceKey);
|
||||
const target = normalizeWorkspaceTabTarget(input.target);
|
||||
if (!workspaceKey || !target) return null;
|
||||
const policy = resolveSheetPolicy(state, workspaceKey, target, "reveal");
|
||||
if (!policy) return null;
|
||||
return placeTargetInSheet({
|
||||
kept: undefined,
|
||||
target,
|
||||
intent: "reveal",
|
||||
policy,
|
||||
parentTabId: resolveOpenParentTabId(state, workspaceKey, input.parentTabId),
|
||||
state: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** A workspace's panes give up keyboard focus while focus is parked or the sheet covers them. */
|
||||
export function selectPaneFocusSuspended(
|
||||
state: Pick<WorkspaceLayoutStore, "focusRestorationByWorkspace" | "sheetsByWorkspace">,
|
||||
@@ -822,10 +844,8 @@ function placeTargetInSheet(input: {
|
||||
function promoteSheetToTab(
|
||||
state: WorkspaceLayoutStore,
|
||||
workspaceKey: string,
|
||||
tabId: string,
|
||||
sheet: SheetTab,
|
||||
): { tabId: string; patch: Partial<WorkspaceLayoutStore> } | null {
|
||||
const sheet = findSheet(state, workspaceKey, tabId);
|
||||
if (!sheet) return null;
|
||||
const { tab } = sheet;
|
||||
const placement = getOpenTabPlacement(state, workspaceKey, tab.target, FOCUSED_PANE_PLACEMENT);
|
||||
const input = {
|
||||
@@ -910,9 +930,9 @@ function dismissSheets(
|
||||
function openSheetTabInFront(
|
||||
state: WorkspaceLayoutStore,
|
||||
workspaceKey: string,
|
||||
tabId: string,
|
||||
sheet: SheetTab,
|
||||
): { tabId: string; patch: Partial<WorkspaceLayoutStore> } | null {
|
||||
const promoted = promoteSheetToTab(state, workspaceKey, tabId);
|
||||
const promoted = promoteSheetToTab(state, workspaceKey, sheet);
|
||||
if (!promoted) return null;
|
||||
const next = { ...state, ...promoted.patch };
|
||||
return {
|
||||
@@ -1208,15 +1228,6 @@ export function createWorkspaceLayoutStore(
|
||||
}));
|
||||
return result.tabId;
|
||||
},
|
||||
opensInSheet: (input) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(input.workspaceKey);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
|
||||
if (!normalizedWorkspaceKey || !normalizedTarget) return false;
|
||||
return (
|
||||
resolveSheetPlacement(get(), normalizedWorkspaceKey, normalizedTarget, input.intent) !==
|
||||
null
|
||||
);
|
||||
},
|
||||
setLayoutMode: (mode) => {
|
||||
set((state) => {
|
||||
if (state.layoutMode === mode) return state;
|
||||
@@ -1225,7 +1236,7 @@ export function createWorkspaceLayoutStore(
|
||||
const next: WorkspaceLayoutStore = { ...state, layoutMode: mode };
|
||||
for (const [workspaceKey, sheets] of Object.entries(state.sheetsByWorkspace)) {
|
||||
for (const sheet of sheets) {
|
||||
const promoted = promoteSheetToTab(next, workspaceKey, sheet.tab.tabId);
|
||||
const promoted = promoteSheetToTab(next, workspaceKey, sheet);
|
||||
Object.assign(
|
||||
next,
|
||||
promoted?.patch ?? withoutSheet(next, workspaceKey, sheet.tab.tabId),
|
||||
@@ -1235,11 +1246,10 @@ export function createWorkspaceLayoutStore(
|
||||
return next;
|
||||
});
|
||||
},
|
||||
openSheetAsTab: (workspaceKey, tabId) => {
|
||||
openTabInFront: (workspaceKey, sheet) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
if (!normalizedWorkspaceKey || !normalizedTabId) return null;
|
||||
const promoted = openSheetTabInFront(get(), normalizedWorkspaceKey, normalizedTabId);
|
||||
if (!normalizedWorkspaceKey) return null;
|
||||
const promoted = openSheetTabInFront(get(), normalizedWorkspaceKey, sheet);
|
||||
if (!promoted) return null;
|
||||
set(promoted.patch);
|
||||
return promoted.tabId;
|
||||
@@ -1569,7 +1579,10 @@ export function createWorkspaceLayoutStore(
|
||||
return normalizedTabId;
|
||||
}
|
||||
// A target sheets do not host becomes the tab it would have opened as.
|
||||
const promoted = openSheetTabInFront(next, normalizedWorkspaceKey, normalizedTabId);
|
||||
const retargeted = findSheet(next, normalizedWorkspaceKey, normalizedTabId);
|
||||
const promoted = retargeted
|
||||
? openSheetTabInFront(next, normalizedWorkspaceKey, retargeted)
|
||||
: null;
|
||||
set(promoted?.patch ?? withoutSheet(next, normalizedWorkspaceKey, normalizedTabId));
|
||||
return promoted?.tabId ?? null;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@ StyleSheet.configure({
|
||||
breakpoints: {
|
||||
xs: 0,
|
||||
sm: 576,
|
||||
medium: 600,
|
||||
md: 720,
|
||||
md: 600,
|
||||
lg: 992,
|
||||
xl: 1200,
|
||||
},
|
||||
@@ -22,7 +21,6 @@ type AppThemes = typeof REGISTERED_THEMES;
|
||||
interface AppBreakpoints {
|
||||
xs: number;
|
||||
sm: number;
|
||||
medium: number;
|
||||
md: number;
|
||||
lg: number;
|
||||
xl: number;
|
||||
|
||||
@@ -9,4 +9,4 @@ export {
|
||||
type ArchiveFinishedStatus,
|
||||
type UseArchiveFinishedSubagentsInput,
|
||||
} from "./use-archive-finished";
|
||||
export { useSubagentOpensInSheet } from "./opens-in-sheet";
|
||||
export { useSubagentSheetTab } from "./sheet-tab";
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import type { SubagentRow } from "./select";
|
||||
|
||||
/** The workspace target a subagent row opens. */
|
||||
export function subagentRowTarget(row: SubagentRow): WorkspaceTabTarget {
|
||||
return row.kind === "provider"
|
||||
? { kind: "provider_subagent", parentAgentId: row.parentAgentId, subagentId: row.id }
|
||||
: { kind: "agent", agentId: row.id };
|
||||
}
|
||||
|
||||
/** Answers, per subagent row, whether opening it in this workspace shows a sheet rather than a tab. */
|
||||
export function useSubagentOpensInSheet(
|
||||
workspaceKey: string | null,
|
||||
): (row: SubagentRow) => boolean {
|
||||
// The rows render the answer, so this subscribes to what the answer reads: the layout mode and
|
||||
// the workspace's tabs. A subagent already open as a tab reveals that tab instead of a sheet,
|
||||
// and a row rendered while that tab existed must re-render once it closes, or choosing the row
|
||||
// would still close the list it sits in.
|
||||
useWorkspaceLayoutStore((state) => state.layoutMode);
|
||||
useWorkspaceLayoutStore((state) =>
|
||||
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
|
||||
);
|
||||
return (row) =>
|
||||
workspaceKey !== null &&
|
||||
useWorkspaceLayoutStore
|
||||
.getState()
|
||||
.opensInSheet({ workspaceKey, target: subagentRowTarget(row), intent: "reveal" });
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CornerUpLeft } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { StatusIcon } from "@/components/status-icon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ComposerTrackMark } from "@/composer/tracks";
|
||||
import { FOOTER_HEIGHT } from "@/constants/layout";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useParentAgent, type ParentAgent } from "./use-parent-agent";
|
||||
@@ -22,6 +22,7 @@ function ParentIcon({
|
||||
}
|
||||
|
||||
const ThemedParentIcon = withUnistyles(ParentIcon);
|
||||
const ICON_SIZE = 16;
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
|
||||
/** Stands in the composer's place on a subagent shown without one: who the parent is, and the way back. */
|
||||
@@ -38,18 +39,12 @@ export function ParentAgentCallout({
|
||||
return (
|
||||
<View style={styles.rail}>
|
||||
<View style={styles.callout} testID="parent-agent-callout">
|
||||
<View style={styles.avatar}>
|
||||
<ThemedParentIcon icon={parent.icon} size={18} uniProps={foregroundColorMapping} />
|
||||
</View>
|
||||
<View style={styles.body}>
|
||||
<Text style={styles.eyebrow}>{t("subagents.parent.parent")}</Text>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{parent.label ?? t("subagents.parent.parentAgent")}
|
||||
</Text>
|
||||
<ComposerTrackMark bucket={parent.statusBucket} />
|
||||
</View>
|
||||
</View>
|
||||
<StatusIcon statusBucket={parent.statusBucket} size={ICON_SIZE} backdrop="surface1">
|
||||
<ThemedParentIcon icon={parent.icon} size={ICON_SIZE} uniProps={foregroundColorMapping} />
|
||||
</StatusIcon>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{parent.label ?? t("subagents.parent.parentAgent")}
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@@ -65,11 +60,13 @@ export function ParentAgentCallout({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// The composer's rail, so the callout sits where the composer would.
|
||||
rail: {
|
||||
minHeight: FOOTER_HEIGHT,
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
padding: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
callout: {
|
||||
width: "100%",
|
||||
@@ -84,31 +81,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[3],
|
||||
paddingRight: { xs: theme.spacing[3], md: theme.spacing[4] },
|
||||
},
|
||||
avatar: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
body: {
|
||||
// Shrinks before the button does, so Open parent never wraps or leaves the row.
|
||||
title: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[0.5],
|
||||
},
|
||||
eyebrow: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
},
|
||||
title: {
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAgentDescriptor } from "@/agent-presentation";
|
||||
import type { PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { useLeaveSheet } from "@/screens/workspace/sheet-pane";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
@@ -25,10 +26,14 @@ export function useParentAgentId(serverId: string, agentId: string): string | nu
|
||||
/** The parent as its own tab presents it, and the way back to it. */
|
||||
export function useParentAgent(serverId: string, parentAgentId: string): ParentAgent {
|
||||
const descriptor = useAgentDescriptor({ kind: "agent", agentId: parentAgentId }, { serverId });
|
||||
// The parent may live in another workspace; navigateToAgent opens it wherever it is.
|
||||
const leaveSheet = useLeaveSheet();
|
||||
// Going back to the parent leaves the sheet the child is shown in. When the child was chosen
|
||||
// from the parent's list, closing the sheet already uncovers the parent; otherwise the parent
|
||||
// may live anywhere, even another workspace, and navigateToAgent opens it wherever it is.
|
||||
const open = useCallback(() => {
|
||||
if (leaveSheet({ kind: "agent", agentId: parentAgentId })) return;
|
||||
navigateToAgent({ serverId, agentId: parentAgentId });
|
||||
}, [parentAgentId, serverId]);
|
||||
}, [leaveSheet, parentAgentId, serverId]);
|
||||
return {
|
||||
label: descriptor.label || null,
|
||||
icon: descriptor.icon,
|
||||
|
||||
+22
-10
@@ -9,7 +9,7 @@ import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { useSubagentOpensInSheet } from "./opens-in-sheet";
|
||||
import { useSubagentSheetTab } from "./sheet-tab";
|
||||
import { selectSubagentsForParent, type SubagentRow } from "./select";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
@@ -116,28 +116,40 @@ afterEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSubagentOpensInSheet", () => {
|
||||
it("re-renders the row's answer as the child's tab opens and closes", () => {
|
||||
describe("useSubagentSheetTab", () => {
|
||||
it("re-renders the row's sheet as the child's tab opens and closes", () => {
|
||||
const row = seedParentWithChild();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
store.setLayoutMode("compact");
|
||||
const target = { kind: "agent", agentId: "child" } as const;
|
||||
|
||||
// The row renders its answer, as the track does, so a stale render is a stale answer.
|
||||
const { result } = renderHook(() => useSubagentOpensInSheet(workspaceKey)(row));
|
||||
expect(result.current).toBe(true);
|
||||
const { result } = renderHook(() => useSubagentSheetTab(workspaceKey, "parent-tab")(row));
|
||||
expect(result.current?.tab.target).toEqual({ kind: "agent", agentId: "child" });
|
||||
|
||||
let tabId: string | null = null;
|
||||
act(() => {
|
||||
const sheetTabId = store.openTab({ workspaceKey, target, intent: "reveal" });
|
||||
tabId = sheetTabId ? store.openSheetAsTab(workspaceKey, sheetTabId) : null;
|
||||
tabId = result.current ? store.openTabInFront(workspaceKey, result.current) : null;
|
||||
});
|
||||
expect(tabId).not.toBeNull();
|
||||
expect(result.current).toBe(false);
|
||||
expect(result.current).toBeNull();
|
||||
|
||||
act(() => {
|
||||
store.closeTab(workspaceKey, tabId ?? "");
|
||||
});
|
||||
expect(result.current).toBe(true);
|
||||
expect(result.current?.tab.tabId).toBe(tabId);
|
||||
});
|
||||
|
||||
it("hands a re-rendered row the same sheet", () => {
|
||||
const row = seedParentWithChild();
|
||||
useWorkspaceLayoutStore.getState().setLayoutMode("compact");
|
||||
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useSubagentSheetTab(workspaceKey, "parent-tab")(row),
|
||||
);
|
||||
const first = result.current;
|
||||
rerender();
|
||||
|
||||
expect(first).not.toBeNull();
|
||||
expect(result.current).toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
selectSheetTab,
|
||||
useWorkspaceLayoutStore,
|
||||
type SheetTab,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import type { SubagentRow } from "./select";
|
||||
|
||||
/** The workspace target a subagent row opens. */
|
||||
export function subagentRowTarget(row: SubagentRow): WorkspaceTabTarget {
|
||||
return row.kind === "provider"
|
||||
? { kind: "provider_subagent", parentAgentId: row.parentAgentId, subagentId: row.id }
|
||||
: { kind: "agent", agentId: row.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers, per subagent row, the sheet that shows it in place of the list, or null when choosing
|
||||
* it reveals a tab instead. `parentTabId` is the tab of the pane the list belongs to.
|
||||
*/
|
||||
export function useSubagentSheetTab(
|
||||
workspaceKey: string | null,
|
||||
parentTabId: string,
|
||||
): (row: SubagentRow) => SheetTab | null {
|
||||
// The rows render the answer, so this subscribes to everything the answer reads. A subagent
|
||||
// already open as a tab reveals that tab instead, and a row rendered while that tab existed
|
||||
// must re-render once it closes.
|
||||
const layoutMode = useWorkspaceLayoutStore((state) => state.layoutMode);
|
||||
const layout = useWorkspaceLayoutStore((state) =>
|
||||
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
|
||||
);
|
||||
const sheets = useWorkspaceLayoutStore((state) =>
|
||||
workspaceKey ? state.sheetsByWorkspace[workspaceKey] : undefined,
|
||||
);
|
||||
return useMemo(() => {
|
||||
// One answer per row until the state it reads changes, so a re-render of the list hands an
|
||||
// open sheet the same tab rather than a fresh one.
|
||||
const answers = new Map<string, SheetTab | null>();
|
||||
return (row) => {
|
||||
if (!workspaceKey) return null;
|
||||
const state = {
|
||||
layoutMode,
|
||||
layoutByWorkspace: layout ? { [workspaceKey]: layout } : {},
|
||||
sheetsByWorkspace: sheets ? { [workspaceKey]: sheets } : {},
|
||||
};
|
||||
const key = `${row.kind}:${row.id}`;
|
||||
const known = answers.get(key);
|
||||
if (known !== undefined) return known;
|
||||
const sheet = selectSheetTab(state, {
|
||||
workspaceKey,
|
||||
target: subagentRowTarget(row),
|
||||
parentTabId,
|
||||
});
|
||||
answers.set(key, sheet);
|
||||
return sheet;
|
||||
};
|
||||
}, [layout, layoutMode, parentTabId, sheets, workspaceKey]);
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useAgentFactsLine } from "@/panels/agent-facts-line";
|
||||
import { getPanelManifest } from "@/panels/panel-manifest";
|
||||
import { useSheetPanePage } from "@/screens/workspace/sheet-pane";
|
||||
import type { SheetTab } from "@/stores/workspace-layout-store";
|
||||
import type { SubagentRow } from "./select";
|
||||
import type { ArchiveFinishedStatus } from "./use-archive-finished";
|
||||
import {
|
||||
@@ -40,10 +42,10 @@ export interface SubagentsTrackProps {
|
||||
onOpenSubagent: (address: AgentAddress) => void;
|
||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
||||
/**
|
||||
* Whether opening this row shows it in a sheet. A sheet stacks over the list and uncovers it on
|
||||
* close, so the list stays open; a tab replaces the list's pane, so choosing it ends the list.
|
||||
* The sheet a row opens, or null when it opens as a tab. A sheet replaces the list in place and
|
||||
* Back returns to it; a tab replaces the list's pane, so choosing it ends the list.
|
||||
*/
|
||||
opensInSheet: (row: SubagentRow) => boolean;
|
||||
sheetTabFor: (row: SubagentRow) => SheetTab | null;
|
||||
onArchiveSubagent: (address: AgentAddress) => void;
|
||||
onArchiveFinished?: () => void;
|
||||
archiveFinishedStatus?: ArchiveFinishedStatus;
|
||||
@@ -71,13 +73,14 @@ export function SubagentsTrack({
|
||||
rows,
|
||||
onOpenSubagent,
|
||||
onOpenProviderSubagent,
|
||||
opensInSheet,
|
||||
sheetTabFor,
|
||||
onArchiveSubagent,
|
||||
onArchiveFinished,
|
||||
archiveFinishedStatus = IDLE_ARCHIVE_FINISHED_STATUS,
|
||||
onDetachSubagent,
|
||||
}: SubagentsTrackProps): ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const sheetPanePage = useSheetPanePage();
|
||||
|
||||
const isArchivingFinished = archiveFinishedStatus.kind === "archiving";
|
||||
const isArchiveFinishedFailed = archiveFinishedStatus.kind === "failed";
|
||||
@@ -88,9 +91,12 @@ export function SubagentsTrack({
|
||||
const pill = buildSubagentPillPresentation(t, rows);
|
||||
const finishedCount = countFinishedSubagents(rows);
|
||||
const showArchiveFinished = finishedCount > 0 || isArchivingFinished || isArchiveFinishedFailed;
|
||||
const sheets = rows.map(sheetTabFor);
|
||||
const pages = sheets.flatMap((sheet) => (sheet ? [sheetPanePage(sheet)] : []));
|
||||
|
||||
return (
|
||||
<ComposerTrackPill
|
||||
pages={pages}
|
||||
testID="subagents-track-header"
|
||||
segments={pill.segments}
|
||||
accessibilityLabel={pill.accessibilityLabel}
|
||||
@@ -105,14 +111,14 @@ export function SubagentsTrack({
|
||||
/>
|
||||
</ComposerTrackActions>
|
||||
) : null}
|
||||
{rows.map((row) => (
|
||||
{rows.map((row, index) => (
|
||||
<SubagentsTrackRow
|
||||
key={row.kind === "paseo" ? `${row.address.serverId}:${row.id}` : row.id}
|
||||
row={row}
|
||||
serverId={serverId}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
onOpenProviderSubagent={onOpenProviderSubagent}
|
||||
opensInSheet={opensInSheet(row)}
|
||||
sheetTabId={sheets[index]?.tab.tabId ?? null}
|
||||
onArchiveSubagent={onArchiveSubagent}
|
||||
onDetachSubagent={onDetachSubagent}
|
||||
/>
|
||||
@@ -184,7 +190,8 @@ interface SubagentsTrackRowProps {
|
||||
row: SubagentRow;
|
||||
onOpenSubagent: (address: AgentAddress) => void;
|
||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
||||
opensInSheet: boolean;
|
||||
/** The sheet page the row opens in place of the list, when it opens in a sheet. */
|
||||
sheetTabId: string | null;
|
||||
onArchiveSubagent: (address: AgentAddress) => void;
|
||||
onDetachSubagent?: (address: AgentAddress) => void;
|
||||
}
|
||||
@@ -194,7 +201,7 @@ function SubagentsTrackRow({
|
||||
row,
|
||||
onOpenSubagent,
|
||||
onOpenProviderSubagent,
|
||||
opensInSheet,
|
||||
sheetTabId,
|
||||
onArchiveSubagent,
|
||||
onDetachSubagent,
|
||||
}: SubagentsTrackRowProps): ReactElement {
|
||||
@@ -269,7 +276,7 @@ function SubagentsTrackRow({
|
||||
<ComposerTrackRow
|
||||
accessibilityLabel={displayLabel}
|
||||
testID={`subagents-track-row-${row.id}`}
|
||||
closeOnSelect={!opensInSheet}
|
||||
opensPage={sheetTabId ?? undefined}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{renderRow}
|
||||
|
||||
@@ -112,7 +112,7 @@ function appendRun<TGroup>(input: {
|
||||
const run = createRun(input.calls, input.isSealed);
|
||||
const host = createHost(run);
|
||||
input.output.push(host);
|
||||
input.groups.set(host.id, input.buildGroup(run));
|
||||
if (run.calls.length > 1) input.groups.set(host.id, input.buildGroup(run));
|
||||
}
|
||||
|
||||
export function prepareGroupedHistory<TGroup>(input: {
|
||||
@@ -180,7 +180,8 @@ export function groupLiveToolCalls<TGroup>(input: {
|
||||
if (hostPlacement === "head") {
|
||||
head.push(createHost(run));
|
||||
}
|
||||
if (hostPlacement === "head" || pendingIncludesHead || !isSealed) {
|
||||
const needsLiveGroup = hostPlacement === "head" || pendingIncludesHead || !isSealed;
|
||||
if (run.calls.length > 1 && needsLiveGroup) {
|
||||
liveGroups.set(run.id, input.buildGroup(run));
|
||||
}
|
||||
pending = [];
|
||||
@@ -217,19 +218,13 @@ export function groupLiveToolCalls<TGroup>(input: {
|
||||
historyGroupUpdatesByHostId: EMPTY_GROUPS,
|
||||
};
|
||||
}
|
||||
if (input.history.groupsByHostId.size === 0) {
|
||||
return {
|
||||
tail: input.history.tail,
|
||||
head,
|
||||
groupsByHostId: liveGroups,
|
||||
historyGroupUpdatesByHostId: EMPTY_GROUPS,
|
||||
};
|
||||
}
|
||||
const groupsByHostId = new Map(input.history.groupsByHostId);
|
||||
let historyGroupUpdatesByHostId: Map<string, TGroup> | null = null;
|
||||
for (const [id, group] of liveGroups) {
|
||||
groupsByHostId.set(id, group);
|
||||
if (input.history.groupsByHostId.has(id)) {
|
||||
const isHistoryHost =
|
||||
input.history.groupsByHostId.has(id) || input.history.pendingCalls[0]?.id === id;
|
||||
if (isHistoryHost) {
|
||||
historyGroupUpdatesByHostId ??= new Map();
|
||||
historyGroupUpdatesByHostId.set(id, group);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,12 @@ import { BottomSheetScrollView, type BottomSheetScrollViewMethods } from "@gorho
|
||||
import { Wrench, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsolatedBottomSheetVisibility } from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import {
|
||||
type ContextBridge,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { ToolCallSheetModal, useToolCallSheetContextBridge } from "@/components/tool-call-sheet";
|
||||
import { useToolCallExpansionContextBridge } from "@/tool-calls/expansion-context";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
interface OverviewToolCallGroupSheetProps {
|
||||
@@ -29,7 +33,12 @@ export function OverviewToolCallGroupSheet({
|
||||
}: OverviewToolCallGroupSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<BottomSheetScrollViewMethods>(null);
|
||||
const contextBridge = useToolCallSheetContextBridge();
|
||||
const sheetContextBridge = useToolCallSheetContextBridge();
|
||||
const expansionContextBridge = useToolCallExpansionContextBridge();
|
||||
const contextBridge = useCallback<ContextBridge>(
|
||||
(content) => sheetContextBridge(expansionContextBridge(content)),
|
||||
[expansionContextBridge, sheetContextBridge],
|
||||
);
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible,
|
||||
onClose,
|
||||
|
||||
@@ -110,11 +110,7 @@ describe("tool call detail-level projection", () => {
|
||||
preparedHistory: prepared,
|
||||
});
|
||||
expect(single.head).toEqual([firstCall]);
|
||||
expect(single.groupsByHostId.get(firstCall.id)?.run).toMatchObject({
|
||||
calls: [firstCall],
|
||||
latest: firstCall,
|
||||
isSealed: false,
|
||||
});
|
||||
expect(single.groupsByHostId.size).toBe(0);
|
||||
|
||||
const grouped = project({
|
||||
level: "overview",
|
||||
@@ -142,18 +138,11 @@ describe("tool call detail-level projection", () => {
|
||||
expect(result.groupsByHostId.get("1")?.isLoading).toBe(true);
|
||||
});
|
||||
|
||||
it("builds a loading aggregate for a one-call run", () => {
|
||||
it("leaves a running lone call visible without an aggregate", () => {
|
||||
const call = toolCall("1", { type: "shell", command: "one" }, { status: "running" });
|
||||
const result = project({ level: "overview", head: [call], isTurnActive: true });
|
||||
const group = result.groupsByHostId.get(call.id);
|
||||
if (!group) {
|
||||
throw new Error("Expected an overview group");
|
||||
}
|
||||
|
||||
expect(group).toMatchObject({
|
||||
isLoading: true,
|
||||
summary: { commandCount: 1 },
|
||||
});
|
||||
expect(result.head).toEqual([call]);
|
||||
expect(result.groupsByHostId.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps an active overview group on its latest call until a visible boundary arrives", () => {
|
||||
@@ -481,8 +470,7 @@ describe("tool call detail-level projection", () => {
|
||||
const result = project({ level: "overview", head: [singleCall, plan, speak] });
|
||||
|
||||
expect(result.head).toEqual([singleCall, plan, speak]);
|
||||
expect(result.groupsByHostId.get(singleCall.id)?.run.calls).toEqual([singleCall]);
|
||||
expect(result.groupsByHostId.size).toBe(1);
|
||||
expect(result.groupsByHostId.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -529,12 +517,58 @@ it.each([false, true])(
|
||||
});
|
||||
expect(result.head).toEqual(autoExpandReasoning ? [thought] : []);
|
||||
expect(result.groupsByHostId.get(call.id)?.run.calls).toEqual(
|
||||
autoExpandReasoning ? [call] : [call, thought],
|
||||
autoExpandReasoning ? undefined : [call, thought],
|
||||
);
|
||||
expect(result.groupsByHostId.get(call.id)?.summary).toEqual(
|
||||
autoExpandReasoning
|
||||
? undefined
|
||||
: expect.objectContaining({
|
||||
commandCount: 1,
|
||||
otherToolCount: 0,
|
||||
thoughtCount: 1,
|
||||
}),
|
||||
);
|
||||
expect(result.groupsByHostId.get(call.id)?.summary).toMatchObject({
|
||||
commandCount: 1,
|
||||
otherToolCount: 0,
|
||||
thoughtCount: autoExpandReasoning ? 0 : 1,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("turns a retained lone call into a group without replacing its row key", () => {
|
||||
const first = toolCall("1", { type: "shell", command: "one" });
|
||||
const second = toolCall("2", { type: "shell", command: "two" });
|
||||
const tail = [assistant("before"), first];
|
||||
const preparedHistory = prepareToolCallHistory("overview", tail);
|
||||
const single = project({ level: "overview", tail, preparedHistory, isTurnActive: true });
|
||||
const grouped = project({
|
||||
level: "overview",
|
||||
tail,
|
||||
head: [second],
|
||||
preparedHistory,
|
||||
isTurnActive: true,
|
||||
});
|
||||
expect(single.groupsByHostId.size).toBe(0);
|
||||
expect(single.tail).toBe(tail);
|
||||
expect(grouped.tail).toBe(single.tail);
|
||||
expect(grouped.head).toEqual([]);
|
||||
expect(grouped.groupsByHostId.get(first.id)?.run.calls).toEqual([first, second]);
|
||||
expect(grouped.historyGroupUpdatesByHostId.get(first.id)).toBe(
|
||||
grouped.groupsByHostId.get(first.id),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows one thought as itself until another groupable item arrives", () => {
|
||||
const thought: StreamItem = {
|
||||
kind: "thought",
|
||||
id: "thought",
|
||||
text: "Checking",
|
||||
status: "ready",
|
||||
timestamp: new Date(0),
|
||||
};
|
||||
const single = project({ level: "overview", tail: [thought] });
|
||||
expect(single.tail).toEqual([thought]);
|
||||
expect(single.groupsByHostId.size).toBe(0);
|
||||
const grouped = project({
|
||||
level: "overview",
|
||||
tail: [thought, toolCall("1", { type: "shell", command: "one" })],
|
||||
});
|
||||
expect(grouped.tail.map((item) => item.id)).toEqual([thought.id]);
|
||||
expect(grouped.groupsByHostId.get(thought.id)?.run.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createContext, useCallback, useContext, useState, useSyncExternalStore } from "react";
|
||||
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import {
|
||||
createToolCallExpansion,
|
||||
type ExpandedToolCallGroups,
|
||||
type ToolCallExpansion,
|
||||
} from "./expansion";
|
||||
|
||||
export const ToolCallExpansionContext = createContext<ToolCallExpansion | null>(null);
|
||||
|
||||
/** One store per viewed agent; switching agents starts with every tool call closed. */
|
||||
export function useAgentToolCallExpansion(agentId: string): ToolCallExpansion {
|
||||
const [owner, setOwner] = useState(() => ({ agentId, expansion: createToolCallExpansion() }));
|
||||
if (owner.agentId === agentId) return owner.expansion;
|
||||
const next = { agentId, expansion: createToolCallExpansion() };
|
||||
setOwner(next);
|
||||
return next.expansion;
|
||||
}
|
||||
|
||||
/** Subscribes the timeline to group toggles only; call toggles never reach it. */
|
||||
export function useExpandedToolCallGroups(expansion: ToolCallExpansion): ExpandedToolCallGroups {
|
||||
return useSyncExternalStore(
|
||||
expansion.subscribeGroups,
|
||||
expansion.getExpandedGroups,
|
||||
expansion.getExpandedGroups,
|
||||
);
|
||||
}
|
||||
|
||||
function useToolCallExpansion(): ToolCallExpansion {
|
||||
const expansion = useContext(ToolCallExpansionContext);
|
||||
if (!expansion) throw new Error("ToolCall requires a ToolCallExpansionContext");
|
||||
return expansion;
|
||||
}
|
||||
|
||||
/** Carries tool call open state into sheets that render their content in a portal. */
|
||||
export function useToolCallExpansionContextBridge(): ContextBridge {
|
||||
const expansion = useToolCallExpansion();
|
||||
return useCallback(
|
||||
(children) => (
|
||||
<ToolCallExpansionContext.Provider value={expansion}>
|
||||
{children}
|
||||
</ToolCallExpansionContext.Provider>
|
||||
),
|
||||
[expansion],
|
||||
);
|
||||
}
|
||||
|
||||
/** Subscribes one tool call to its own open state. */
|
||||
export function useToolCallExpanded(input: {
|
||||
callId: string;
|
||||
defaultExpanded: boolean;
|
||||
standalone: boolean;
|
||||
}) {
|
||||
const expansion = useToolCallExpansion();
|
||||
const { callId, defaultExpanded, standalone } = input;
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => expansion.subscribeCall(callId, listener),
|
||||
[expansion, callId],
|
||||
);
|
||||
const getSnapshot = useCallback(
|
||||
() => expansion.isCallExpanded(callId, defaultExpanded),
|
||||
[expansion, callId, defaultExpanded],
|
||||
);
|
||||
const expanded = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
const toggle = useCallback(() => {
|
||||
expansion.setCallExpanded({ callId, expanded: !expanded, standalone });
|
||||
}, [expansion, callId, expanded, standalone]);
|
||||
return { expanded, toggle };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createToolCallExpansion } from "./expansion";
|
||||
|
||||
function observe(expansion: ReturnType<typeof createToolCallExpansion>) {
|
||||
const notified: string[] = [];
|
||||
expansion.subscribeCall("first", () => notified.push("first"));
|
||||
expansion.subscribeCall("second", () => notified.push("second"));
|
||||
expansion.subscribeGroups(() => notified.push("timeline"));
|
||||
return notified;
|
||||
}
|
||||
|
||||
describe("tool call expansion", () => {
|
||||
it("opens a lone call and its future group without notifying the timeline", () => {
|
||||
const expansion = createToolCallExpansion();
|
||||
const notified = observe(expansion);
|
||||
const groupsBefore = expansion.getExpandedGroups();
|
||||
|
||||
expansion.setCallExpanded({ callId: "first", expanded: true, standalone: true });
|
||||
|
||||
expect(notified).toEqual(["first"]);
|
||||
expect(expansion.getExpandedGroups()).toBe(groupsBefore);
|
||||
expect(expansion.isCallExpanded("first", false)).toBe(true);
|
||||
expect(expansion.getExpandedGroups().has("first")).toBe(true);
|
||||
});
|
||||
|
||||
it("closes the lone call and its future group together", () => {
|
||||
const expansion = createToolCallExpansion();
|
||||
const notified = observe(expansion);
|
||||
const groupsBefore = expansion.getExpandedGroups();
|
||||
|
||||
expansion.setCallExpanded({ callId: "first", expanded: true, standalone: true });
|
||||
expansion.setCallExpanded({ callId: "first", expanded: false, standalone: true });
|
||||
|
||||
expect(notified).toEqual(["first", "first"]);
|
||||
expect(expansion.getExpandedGroups()).toBe(groupsBefore);
|
||||
expect(expansion.isCallExpanded("first", false)).toBe(false);
|
||||
expect(expansion.getExpandedGroups().has("first")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a call open when its group closes and reopens", () => {
|
||||
const expansion = createToolCallExpansion();
|
||||
const notified = observe(expansion);
|
||||
expansion.setCallExpanded({ callId: "first", expanded: true, standalone: true });
|
||||
const groupsWhenFormed = expansion.getExpandedGroups();
|
||||
|
||||
expansion.setGroupExpanded("first", false);
|
||||
const closedGroups = expansion.getExpandedGroups();
|
||||
expect(closedGroups).not.toBe(groupsWhenFormed);
|
||||
expect(closedGroups.has("first")).toBe(false);
|
||||
expect(expansion.isCallExpanded("first", false)).toBe(true);
|
||||
|
||||
expansion.setGroupExpanded("first", true);
|
||||
expect(expansion.getExpandedGroups()).not.toBe(closedGroups);
|
||||
expect(expansion.getExpandedGroups().has("first")).toBe(true);
|
||||
expect(expansion.isCallExpanded("first", false)).toBe(true);
|
||||
expect(notified).toEqual(["first", "timeline", "timeline"]);
|
||||
});
|
||||
|
||||
it("toggles a call inside a group without touching the group", () => {
|
||||
const expansion = createToolCallExpansion();
|
||||
const notified = observe(expansion);
|
||||
expansion.setGroupExpanded("first", true);
|
||||
const groups = expansion.getExpandedGroups();
|
||||
|
||||
expansion.setCallExpanded({ callId: "first", expanded: true, standalone: false });
|
||||
expansion.setCallExpanded({ callId: "first", expanded: false, standalone: false });
|
||||
|
||||
expect(notified).toEqual(["timeline", "first", "first"]);
|
||||
expect(expansion.getExpandedGroups()).toBe(groups);
|
||||
expect(groups.has("first")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the call's default until the user toggles it", () => {
|
||||
const expansion = createToolCallExpansion();
|
||||
expect(expansion.isCallExpanded("first", true)).toBe(true);
|
||||
expansion.setCallExpanded({ callId: "first", expanded: false, standalone: true });
|
||||
expect(expansion.isCallExpanded("first", true)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
type Listener = () => void;
|
||||
|
||||
/** Which tool call groups are open. A new value is published only when a group is toggled. */
|
||||
export interface ExpandedToolCallGroups {
|
||||
has(groupId: string): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open state of one agent's tool calls and tool call groups.
|
||||
*
|
||||
* A group takes its first call's id. Opening a lone call (`standalone`) also opens the group it
|
||||
* will host once a second call arrives, without publishing a group change: the timeline renders
|
||||
* that row again when the group forms and reads the open state then. A call toggle notifies only
|
||||
* that call's subscribers.
|
||||
*/
|
||||
export function createToolCallExpansion() {
|
||||
const calls = new Map<string, boolean>();
|
||||
const callListeners = new Map<string, Set<Listener>>();
|
||||
const groups = new Set<string>();
|
||||
const groupListeners = new Set<Listener>();
|
||||
let expandedGroups = createExpandedGroups();
|
||||
|
||||
function createExpandedGroups(): ExpandedToolCallGroups {
|
||||
return { has: (groupId) => groups.has(groupId) };
|
||||
}
|
||||
|
||||
function writeGroup(groupId: string, expanded: boolean) {
|
||||
if (expanded) groups.add(groupId);
|
||||
else groups.delete(groupId);
|
||||
}
|
||||
|
||||
return {
|
||||
isCallExpanded: (callId: string, defaultExpanded: boolean) =>
|
||||
calls.get(callId) ?? defaultExpanded,
|
||||
subscribeCall(callId: string, listener: Listener) {
|
||||
let listeners = callListeners.get(callId);
|
||||
if (!listeners) {
|
||||
listeners = new Set();
|
||||
callListeners.set(callId, listeners);
|
||||
}
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) callListeners.delete(callId);
|
||||
};
|
||||
},
|
||||
setCallExpanded(input: { callId: string; expanded: boolean; standalone: boolean }) {
|
||||
calls.set(input.callId, input.expanded);
|
||||
if (input.standalone) writeGroup(input.callId, input.expanded);
|
||||
for (const listener of callListeners.get(input.callId) ?? []) listener();
|
||||
},
|
||||
getExpandedGroups: () => expandedGroups,
|
||||
subscribeGroups(listener: Listener) {
|
||||
groupListeners.add(listener);
|
||||
return () => {
|
||||
groupListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
setGroupExpanded(groupId: string, expanded: boolean) {
|
||||
writeGroup(groupId, expanded);
|
||||
expandedGroups = createExpandedGroups();
|
||||
for (const listener of groupListeners) listener();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ToolCallExpansion = ReturnType<typeof createToolCallExpansion>;
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
type PanelIconProps,
|
||||
type PanelPresentation,
|
||||
} from "@/panels/panel-registry";
|
||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
||||
import {
|
||||
getTerminalProfileIcon,
|
||||
resolveTerminalProfiles,
|
||||
@@ -109,7 +108,6 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
||||
const { config } = useDaemonConfig(serverId);
|
||||
const plugins = useInstalledPlugins();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
ensurePanelsRegistered();
|
||||
|
||||
const launchSelection = useCallback(
|
||||
(selection: NewTabSelection) => (destination: WorkspaceTabLaunchDestination) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DEFAULT_PANE_ID,
|
||||
findPaneById,
|
||||
findPaneContainingTab,
|
||||
selectSheetTab,
|
||||
useWorkspaceLayoutStore,
|
||||
type WorkspaceTabPlacement,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
@@ -144,7 +145,7 @@ export function openPreferredWorkspacePreview(
|
||||
// The sheet is already a single replaceable slot, so it needs no preview tab.
|
||||
if (
|
||||
existing ||
|
||||
store.opensInSheet({ workspaceKey: input.workspaceKey, target: input.target, intent: "reveal" })
|
||||
selectSheetTab(store, { workspaceKey: input.workspaceKey, target: input.target }) !== null
|
||||
) {
|
||||
return openPreferredWorkspaceTarget(input);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ export function useBottomSheetModal(): never {
|
||||
export function useBottomSheet(): never {
|
||||
throw new Error("useBottomSheet is unavailable in tests");
|
||||
}
|
||||
// A drag area only hands its gesture to a sheet; with no sheet drawn there is nothing to drag.
|
||||
export function useBottomSheetGestureHandlers(): never {
|
||||
throw new Error("useBottomSheetGestureHandlers is unavailable in tests");
|
||||
}
|
||||
export const BottomSheetModalInternalProvider = PassThrough;
|
||||
export const INITIAL_CONTAINER_LAYOUT = {
|
||||
height: 0,
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
// The package ships untranspiled Flow, which the unit project cannot parse. Insets are a device
|
||||
// fact no test asserts on, so they are zero.
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
// The package ships untranspiled Flow, which the unit project cannot parse. Like the real package,
|
||||
// the hook reads the insets context; outside a provider the insets are zero.
|
||||
const ZERO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 } as const;
|
||||
|
||||
export const useSafeAreaInsets = () => ZERO_INSETS;
|
||||
export interface EdgeInsets {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
export const SafeAreaInsetsContext = createContext<EdgeInsets | null>(ZERO_INSETS);
|
||||
export const useSafeAreaInsets = () => useContext(SafeAreaInsetsContext) ?? ZERO_INSETS;
|
||||
export const useSafeAreaFrame = () => ({ x: 0, y: 0, width: 0, height: 0 });
|
||||
export const SafeAreaProvider = ({ children }: { children?: unknown }) => children;
|
||||
export const SafeAreaView = ({ children }: { children?: unknown }) => children;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user