mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 04:32:57 +00:00
Compare commits
13
Commits
main
...
v0.14.0-beta.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a05491be5 | ||
|
|
28bb7e29be | ||
|
|
d6d04b0c56 | ||
|
|
20b2dad610 | ||
|
|
58ca9a9b30 | ||
|
|
dfa9977047 | ||
|
|
4f46e094a1 | ||
|
|
dd914c1479 | ||
|
|
025e7ef1d8 | ||
|
|
b4e837a9fe | ||
|
|
f098f77f4a | ||
|
|
f59ebd4fd5 | ||
|
|
aae97b2bdc |
+11
-18
@@ -133,11 +133,12 @@ jobs:
|
|||||||
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||||
|
|
||||||
|
# The locked Playwright installers do not support ubuntu-latest's Ubuntu 26.04.
|
||||||
typecheck:
|
typecheck:
|
||||||
name: typecheck
|
name: typecheck
|
||||||
needs: changes
|
needs: changes
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -395,7 +396,7 @@ jobs:
|
|||||||
name: app-tests
|
name: app-tests
|
||||||
needs: changes
|
needs: changes
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }}
|
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -417,19 +418,11 @@ jobs:
|
|||||||
- name: Run app unit tests
|
- name: Run app unit tests
|
||||||
run: npm run test --workspace=@getpaseo/app
|
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:
|
playwright-1:
|
||||||
name: playwright (shard 1/8)
|
name: playwright (shard 1/8)
|
||||||
needs: changes
|
needs: changes
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_SHARD: "1/8"
|
PLAYWRIGHT_SHARD: "1/8"
|
||||||
PLAYWRIGHT_ARTIFACT: "1"
|
PLAYWRIGHT_ARTIFACT: "1"
|
||||||
@@ -485,7 +478,7 @@ jobs:
|
|||||||
name: playwright (shard 2/8)
|
name: playwright (shard 2/8)
|
||||||
needs: changes
|
needs: changes
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_SHARD: "2/8"
|
PLAYWRIGHT_SHARD: "2/8"
|
||||||
PLAYWRIGHT_ARTIFACT: "2"
|
PLAYWRIGHT_ARTIFACT: "2"
|
||||||
@@ -495,7 +488,7 @@ jobs:
|
|||||||
name: playwright (shard 3/8)
|
name: playwright (shard 3/8)
|
||||||
needs: changes
|
needs: changes
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_SHARD: "3/8"
|
PLAYWRIGHT_SHARD: "3/8"
|
||||||
PLAYWRIGHT_ARTIFACT: "3"
|
PLAYWRIGHT_ARTIFACT: "3"
|
||||||
@@ -505,7 +498,7 @@ jobs:
|
|||||||
name: playwright (shard 4/8)
|
name: playwright (shard 4/8)
|
||||||
needs: changes
|
needs: changes
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_SHARD: "4/8"
|
PLAYWRIGHT_SHARD: "4/8"
|
||||||
PLAYWRIGHT_ARTIFACT: "4"
|
PLAYWRIGHT_ARTIFACT: "4"
|
||||||
@@ -515,7 +508,7 @@ jobs:
|
|||||||
name: playwright (shard 5/8)
|
name: playwright (shard 5/8)
|
||||||
needs: changes
|
needs: changes
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_SHARD: "5/8"
|
PLAYWRIGHT_SHARD: "5/8"
|
||||||
PLAYWRIGHT_ARTIFACT: "5"
|
PLAYWRIGHT_ARTIFACT: "5"
|
||||||
@@ -525,7 +518,7 @@ jobs:
|
|||||||
name: playwright (shard 6/8)
|
name: playwright (shard 6/8)
|
||||||
needs: changes
|
needs: changes
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_SHARD: "6/8"
|
PLAYWRIGHT_SHARD: "6/8"
|
||||||
PLAYWRIGHT_ARTIFACT: "6"
|
PLAYWRIGHT_ARTIFACT: "6"
|
||||||
@@ -535,7 +528,7 @@ jobs:
|
|||||||
name: playwright (shard 7/8)
|
name: playwright (shard 7/8)
|
||||||
needs: changes
|
needs: changes
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_SHARD: "7/8"
|
PLAYWRIGHT_SHARD: "7/8"
|
||||||
PLAYWRIGHT_ARTIFACT: "7"
|
PLAYWRIGHT_ARTIFACT: "7"
|
||||||
@@ -545,7 +538,7 @@ jobs:
|
|||||||
name: playwright (shard 8/8)
|
name: playwright (shard 8/8)
|
||||||
needs: changes
|
needs: changes
|
||||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-24.04
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_SHARD: "8/8"
|
PLAYWRIGHT_SHARD: "8/8"
|
||||||
PLAYWRIGHT_ARTIFACT: "8"
|
PLAYWRIGHT_ARTIFACT: "8"
|
||||||
|
|||||||
+6
-3
@@ -1,6 +1,6 @@
|
|||||||
# Changelog
|
# 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.
|
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
|
### Added
|
||||||
|
|
||||||
- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938))
|
- 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))
|
- 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 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))
|
- 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))
|
- 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))
|
- 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))
|
- 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))
|
- 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))
|
- 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 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 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 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 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 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 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))
|
- Fixed model selectors staying open over Settings ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
|
||||||
|
|
||||||
### Notes
|
### 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 服务器。
|
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
### 新增
|
### 新增
|
||||||
|
|
||||||
- 插件现在可以为特定 Agent 提供工具 ([#7938](https://github.com/getpaseo/paseo/pull/7938))
|
- 插件现在可以为特定 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))
|
- 插件现在可以在每次打开 Agent 会话时添加 MCP 服务器 ([#7983](https://github.com/getpaseo/paseo/pull/7983))
|
||||||
- 语音设置新增可下载的本地语音模型,支持使用 SenseVoice 和 Whisper 进行中文听写 ([#7295](https://github.com/getpaseo/paseo/pull/7295))
|
- 语音设置新增可下载的本地语音模型,支持使用 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))
|
- 新增 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 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782))
|
||||||
- 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784))
|
- 在子 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))
|
- 问题卡片新增可点击链接和可选中的描述文本 ([#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))
|
- 减少网页端长会话导入列表的操作卡顿 ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
|
||||||
- 增大手机上项目和工作区菜单按钮的点击区域 ([#7928](https://github.com/getpaseo/paseo/pull/7928))
|
- 增大手机上项目和工作区菜单按钮的点击区域 ([#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))
|
- 修复工作区外的文件夹链接使 Files 显示访问错误的问题 ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
|
||||||
- 修复手机上命令建议与不断增高的 Composer 重叠的问题 ([#7910](https://github.com/getpaseo/paseo/pull/7910))
|
- 修复手机上命令建议与不断增高的 Composer 重叠的问题 ([#7910](https://github.com/getpaseo/paseo/pull/7910))
|
||||||
- 修复 Markdown 裸链接丢失端口或在路径冒号处截断的问题 ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
|
- 修复 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))
|
- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#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))
|
- 修复 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))
|
- 修复离开聊天后文件链接提示仍遮挡预览的问题 ([#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))
|
- 修复模型选择器在打开设置后仍然显示的问题 ([#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.
|
- **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.
|
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
|
`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.
|
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
|
### 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.
|
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 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 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.
|
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.
|
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
|
(`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
|
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
|
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
|
other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet lands on
|
||||||
subagent chosen from the subagents list included, lands on top of it, and closing the top sheet
|
top of it, and closing the top sheet (drag, Close, backdrop, or Android Back) uncovers the one below.
|
||||||
(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.
|
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
|
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
|
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
|
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.
|
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
|
Both kinds render the same `SheetPane` (`packages/app/src/screens/workspace/sheet-pane/`): the
|
||||||
handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail
|
header (icon, title, Back when there is a list to return to, Open as tab, Close), the pane, its
|
||||||
holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside
|
bottom inset, and its focus. Only the container differs, a workspace sheet in `sheet-tabs.tsx` or a
|
||||||
Close, so the title keeps its width. Compact sheets use the app overlay host: a workspace-local
|
menu page in `sheet-pane/page.tsx`, so a fix to what a sheet shows lands in one place. The
|
||||||
host sits behind the app-level left sidebar. The workspace environment bridge preserves existing
|
container owns Android Back; the pane never subscribes to it.
|
||||||
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
|
Sheets are modal and cover both compact sidebars as well as the workspace. The header drags the
|
||||||
assemble provider internals. Wide layouts retain local hosting and pane geometry. Both hosts
|
sheet through `SheetDragArea`, because the panel scrolls on its own and content panning would fight
|
||||||
forward the same Gorhom modal queue; neither creates another stack. See
|
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).
|
[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.
|
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.
|
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
|
## Recipe for a new anchored panel
|
||||||
|
|
||||||
Before you write a new one, ask:
|
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**.
|
- **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).
|
- **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** — 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".
|
- **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).
|
- **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).
|
- **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
|
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.
|
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
|
A row whose result opens a sheet opens it as a page of the menu's own sheet, never as a second
|
||||||
sheet and uncovers it on close, so the menu is where the user lands next. A row that opens a tab
|
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
|
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
|
## 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
|
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.
|
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
|
## Hover intent
|
||||||
|
|
||||||
A flyout **overlaps its parent by 5pt** rather than sitting beside it. With a gap there is a strip
|
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
|
entering animation never ends at the off-screen measuring position; on a slow machine that
|
||||||
write moved the open menu back off-screen.
|
write moved the open menu back off-screen.
|
||||||
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
|
- **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
|
- **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
|
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
|
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",
|
"name": "paseo",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0-beta.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0-beta.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -37590,7 +37590,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "6.10.4",
|
"@codemirror/commands": "6.10.4",
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
@@ -39793,12 +39793,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.14.0-beta.1",
|
"@getpaseo/client": "0.14.0-beta.2",
|
||||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||||
"@getpaseo/server": "0.14.0-beta.1",
|
"@getpaseo/server": "0.14.0-beta.2",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -40045,10 +40045,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||||
"@getpaseo/relay": "0.14.0-beta.1",
|
"@getpaseo/relay": "0.14.0-beta.2",
|
||||||
"ws": "^8.20.0",
|
"ws": "^8.20.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -40060,7 +40060,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0-beta.2",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -40304,7 +40304,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0-beta.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -40350,7 +40350,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -40379,15 +40379,15 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@getpaseo/plugin",
|
"name": "@getpaseo/plugin",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^1.4.0",
|
"@agentclientprotocol/sdk": "^1.4.0",
|
||||||
"i18next": "^26.3.0",
|
"i18next": "^26.3.0",
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@getpaseo/client": "0.14.0-beta.1",
|
"@getpaseo/client": "0.14.0-beta.2",
|
||||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
@@ -40397,8 +40397,8 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@getpaseo/client": "0.14.0-beta.1",
|
"@getpaseo/client": "0.14.0-beta.2",
|
||||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||||
"react": ">=19.1.0 <20",
|
"react": ">=19.1.0 <20",
|
||||||
"react-native": ">=0.81.5",
|
"react-native": ">=0.81.5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -40420,7 +40420,7 @@
|
|||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"semver": "^7.8.5",
|
"semver": "^7.8.5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -40447,7 +40447,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/ciphers": "^2.4.0",
|
"@noble/ciphers": "^2.4.0",
|
||||||
"@noble/curves": "^2.4.0",
|
"@noble/curves": "^2.4.0",
|
||||||
@@ -40668,16 +40668,16 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@getpaseo/client": "0.14.0-beta.1",
|
"@getpaseo/client": "0.14.0-beta.2",
|
||||||
"@getpaseo/highlight": "0.14.0-beta.1",
|
"@getpaseo/highlight": "0.14.0-beta.2",
|
||||||
"@getpaseo/plugin": "0.14.0-beta.1",
|
"@getpaseo/plugin": "0.14.0-beta.2",
|
||||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
"@getpaseo/protocol": "0.14.0-beta.2",
|
||||||
"@getpaseo/relay": "0.14.0-beta.1",
|
"@getpaseo/relay": "0.14.0-beta.2",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
@@ -41598,7 +41598,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.29.1",
|
"@cloudflare/vite-plugin": "^1.29.1",
|
||||||
"@cloudflare/workers-types": "^4.20260317.1",
|
"@cloudflare/workers-types": "^4.20260317.1",
|
||||||
@@ -43208,7 +43208,7 @@
|
|||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"name": "@getpaseo/builtin-plugins",
|
"name": "@getpaseo/builtin-plugins",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0-beta.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/plugin": "*",
|
"@getpaseo/plugin": "*",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0-beta.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ import { expectComposerVisible } from "../support/helpers/composer";
|
|||||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
import {
|
import {
|
||||||
|
expectSubagentRowGone,
|
||||||
expectSubagentRowVisible,
|
expectSubagentRowVisible,
|
||||||
openSubagentFromCompactTrack,
|
openSubagentFromCompactTrack,
|
||||||
seedSubagent,
|
seedSubagent,
|
||||||
} from "../support/helpers/subagents";
|
} from "../support/helpers/subagents";
|
||||||
import {
|
import {
|
||||||
|
backToList,
|
||||||
closeSheet,
|
closeSheet,
|
||||||
|
expectNoSheetOpen,
|
||||||
|
expectOneSheetOpen,
|
||||||
expectOpenTabCount,
|
expectOpenTabCount,
|
||||||
openSheetAsTab,
|
openSheetAsTab,
|
||||||
expectDeclinedDiscardPrompt,
|
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 },
|
{ client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId },
|
||||||
{ parentId: workspace.agentId, title: "Sheet child" },
|
{ 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 openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
|
||||||
await expectOpenTabCount(page, 1);
|
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 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);
|
await openSubagentFromCompactTrack(page, child.id);
|
||||||
|
|
||||||
const childSheet = sheet(page, child.title);
|
const childSheet = sheet(page, child.title);
|
||||||
await expect(childSheet).toBeVisible();
|
await expect(childSheet).toBeVisible();
|
||||||
await expectSheetAtSnap(page, child.title);
|
await expectSheetAtSnap(page, child.title);
|
||||||
|
await expectSubagentRowGone(page, child.id);
|
||||||
|
await expectOneSheetOpen(page);
|
||||||
await expect(childSheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0);
|
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
|
// The composer's model, thinking, and mode stand in for it, resolved the same way. An idle
|
||||||
// seeded agent has no context usage yet.
|
// 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 expectOpenTabCount(page, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("closing the subagent's sheet uncovers the list", async () => {
|
await test.step("Back returns the sheet to the list", async () => {
|
||||||
await closeSheet(page, child.title);
|
await backToList(page, child.title);
|
||||||
await expectSubagentRowVisible(page, child.id);
|
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 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);
|
const childSheet = sheet(page, child.title);
|
||||||
await expectSheetAtSnap(page, child.title);
|
await expectSheetAtSnap(page, child.title);
|
||||||
await expect(childSheet.getByTestId("composer-parent-agent-pill")).toHaveCount(0);
|
await expect(childSheet.getByTestId("composer-parent-agent-pill")).toHaveCount(0);
|
||||||
await childSheet.getByTestId("parent-agent-callout-open").click();
|
await childSheet.getByTestId("parent-agent-callout-open").click();
|
||||||
await expect(childSheet).toHaveCount(0);
|
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 expectOpenTabCount(page, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
|
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 expectSheetAtSnap(page, child.title);
|
||||||
await openSheetAsTab(page, child.title);
|
await openSheetAsTab(page, child.title);
|
||||||
await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0);
|
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 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
|
await page
|
||||||
.getByRole("button", { name: /Switch tabs/ })
|
.getByRole("button", { name: /Switch tabs/ })
|
||||||
.filter({ visible: true })
|
.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 openSubagentFromCompactTrack(page, child.id);
|
||||||
await expectSheetAtSnap(page, child.title);
|
await expectSheetAtSnap(page, child.title);
|
||||||
await closeSheet(page, child.title);
|
await backToList(page, child.title);
|
||||||
await expectSubagentRowVisible(page, child.id);
|
await expectSubagentRowVisible(page, child.id);
|
||||||
await tapSheetBackdrop(page);
|
await tapSheetBackdrop(page);
|
||||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
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 type { Locator } from "@playwright/test";
|
||||||
import { expect, test, type Page } from "../support/fixtures";
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
|
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||||
import { expectComposerVisible } from "../support/helpers/composer";
|
import { expectComposerVisible } from "../support/helpers/composer";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
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();
|
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 { 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 { expectComposerVisible } from "../support/helpers/composer";
|
||||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
import {
|
||||||
|
readReactRenders,
|
||||||
|
recordReactRenders,
|
||||||
|
resetReactRenders,
|
||||||
|
} from "../support/helpers/react-renders";
|
||||||
|
|
||||||
type WebSocketMessage = string | Buffer;
|
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 {
|
return {
|
||||||
waitForFirstCompleted: () => firstCompleted,
|
waitForFirstCompleted: () => firstCompleted,
|
||||||
|
appendCompletedCalls,
|
||||||
release(extraCompletedCalls = 0) {
|
release(extraCompletedCalls = 0) {
|
||||||
isHolding = false;
|
isHolding = false;
|
||||||
if (extraCompletedCalls > 0 && (!firstCallId || !firstCompletedMessage)) {
|
appendCompletedCalls(extraCompletedCalls);
|
||||||
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;
|
|
||||||
for (const message of heldMessages.splice(0)) {
|
for (const message of heldMessages.splice(0)) {
|
||||||
sendToPage?.(shiftTimelineSequence(message, sequenceOffset));
|
sendToPage?.(shiftTimelineSequence(message, sequenceOffset));
|
||||||
}
|
}
|
||||||
@@ -139,17 +150,24 @@ async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function configureOverviewToolCalls(page: Page): Promise<void> {
|
type OverviewChatDisplay = "collapseToolCalls" | "collapseTurns";
|
||||||
await page.addInitScript(() => {
|
|
||||||
|
// 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(
|
localStorage.setItem(
|
||||||
"@paseo:app-settings",
|
"@paseo:app-settings",
|
||||||
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
|
JSON.stringify({ chatDisplay: display, autoExpandReasoning: true }),
|
||||||
);
|
);
|
||||||
});
|
}, chatDisplay);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createOverviewAgent(page: Page, title: string) {
|
async function createOverviewAgent(
|
||||||
await configureOverviewToolCalls(page);
|
page: Page,
|
||||||
|
title: string,
|
||||||
|
chatDisplay: OverviewChatDisplay = "collapseToolCalls",
|
||||||
|
) {
|
||||||
|
await configureOverviewToolCalls(page, chatDisplay);
|
||||||
return seedMockAgentWorkspace({
|
return seedMockAgentWorkspace({
|
||||||
repoPrefix: "tool-call-overview-sheet-",
|
repoPrefix: "tool-call-overview-sheet-",
|
||||||
title,
|
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.describe("compact overview tool calls", () => {
|
||||||
test.use({ viewport: COMPACT_VIEWPORT, hasTouch: true });
|
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 agent.client.sendAgentMessage(agent.agentId, "Exercise the overview tool-call sheet.");
|
||||||
await gate.waitForFirstCompleted();
|
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();
|
const group = page.getByTestId("tool-call-group").first();
|
||||||
await expect(group).toBeVisible();
|
await expect(group).toBeVisible();
|
||||||
|
await expect(page.getByTestId("tool-call-group-sheet")).toBeHidden();
|
||||||
await group.click();
|
await group.click();
|
||||||
|
|
||||||
const sheet = page.getByTestId("tool-call-group-sheet");
|
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);
|
test.setTimeout(120_000);
|
||||||
const agent = await createOverviewAgent(page, "Desktop overview tool calls");
|
const agent = await createOverviewAgent(page, "Closed desktop overview tool calls");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const gate = await holdStreamAfterFirstCompletedToolCall(page, agent.agentId);
|
const gate = await gateSecondToolCall(page, agent.agentId);
|
||||||
await openOverviewAgent(page, agent);
|
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();
|
await gate.waitForFirstCompleted();
|
||||||
|
const lone = firstToolCall(page);
|
||||||
const group = page.getByTestId("tool-call-group").first();
|
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();
|
await expect(group).toBeVisible();
|
||||||
|
await expect(group.getByTestId("tool-call-badge")).toHaveCount(0);
|
||||||
// Keep streaming layout changes out of the pointer-down/pointer-up interval.
|
expect(Math.abs((await topOf(group)) - loneTop)).toBeLessThanOrEqual(2);
|
||||||
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);
|
|
||||||
} finally {
|
} finally {
|
||||||
await agent.cleanup();
|
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 { 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 { expectComposerVisible } from "../support/helpers/composer";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
|
||||||
type WebSocketMessage = string | Buffer;
|
|
||||||
|
|
||||||
interface ShimmerEvidence {
|
interface ShimmerEvidence {
|
||||||
animationDuration: string;
|
animationDuration: string;
|
||||||
endPx: number;
|
endPx: number;
|
||||||
@@ -14,127 +12,6 @@ interface ShimmerEvidence {
|
|||||||
startPx: number;
|
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> {
|
async function readShimmerEvidence(locator: Locator): Promise<ShimmerEvidence> {
|
||||||
return locator.evaluate((root) => {
|
return locator.evaluate((root) => {
|
||||||
const shimmer = Array.from(root.querySelectorAll<HTMLElement>("*")).find((element) =>
|
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 {
|
interface MessagePreviewWatch {
|
||||||
/** Frames after the watch started on which the fade or "Show more" control was missing. */
|
/** Frames after the watch started on which the fade or "Show more" control was missing. */
|
||||||
framesWithoutClip: number[];
|
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) {
|
async function expectSenderAlignedWithBubble(attribution: Locator) {
|
||||||
const edges = await attribution.evaluate((button) => {
|
const edges = await attribution.evaluate((button) => {
|
||||||
const bubble = button.nextElementSibling;
|
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).toContainText(/read 1 file/i);
|
||||||
await expect(group.getByRole("button").first()).not.toContainText(/thought/i);
|
await expect(group.getByRole("button").first()).not.toContainText(/thought/i);
|
||||||
await group.getByRole("button").first().click();
|
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);
|
const command = JSON.parse(line);
|
||||||
let data = {};
|
let data = {};
|
||||||
if (command.type === "get_available_models") {
|
if (command.type === "get_available_models") {
|
||||||
|
if (process.argv.includes("--stall-model-discovery")) continue;
|
||||||
data = {
|
data = {
|
||||||
models: [
|
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`.
|
`MarkdownFormattingRegression` from `./e2e/mobile/markdown-formatting/native`.
|
||||||
Open the same Metro URL in a browser to compare web. Restore `index.ts` afterward.
|
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
|
The package patch contains `InlineCodeFormattingTest`, which exercises actual
|
||||||
Android spans in Robolectric native graphics mode, covering draw and measurement,
|
Android spans in Robolectric native graphics mode, covering draw and measurement,
|
||||||
span order, code inside paragraph/heading sizes, bold/italic composition, and
|
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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useCallback, useState } from "react";
|
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 { StyleSheet } from "react-native-unistyles";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||||
@@ -10,6 +10,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
scroll: { backgroundColor: theme.colors.surface0 },
|
scroll: { backgroundColor: theme.colors.surface0 },
|
||||||
content: { padding: 32, paddingTop: 60 },
|
content: { padding: 32, paddingTop: 60 },
|
||||||
title: { color: theme.colors.foreground },
|
title: { color: theme.colors.foreground },
|
||||||
|
wrapping: { width: 300 },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
@@ -32,7 +33,15 @@ const source = [
|
|||||||
"Normal **bold** and *italic* prose.",
|
"Normal **bold** and *italic* prose.",
|
||||||
].join("\n\n");
|
].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() {
|
export default function MarkdownFormattingRegression() {
|
||||||
|
const [wrapping, setWrapping] = useState(false);
|
||||||
|
const toggleWrapping = useCallback(() => setWrapping((value) => !value), []);
|
||||||
const [compact, setCompact] = useState(false);
|
const [compact, setCompact] = useState(false);
|
||||||
const toggleCompact = useCallback(() => setCompact((value) => !value), []);
|
const toggleCompact = useCallback(() => setCompact((value) => !value), []);
|
||||||
return (
|
return (
|
||||||
@@ -44,7 +53,14 @@ export default function MarkdownFormattingRegression() {
|
|||||||
title={compact ? "Use regular text" : "Use compact text"}
|
title={compact ? "Use regular text" : "Use compact text"}
|
||||||
onPress={toggleCompact}
|
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>
|
</ScrollView>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
</QueryClientProvider>
|
</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 });
|
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 {
|
export function sheetHeader(page: Page, label: string): Locator {
|
||||||
return page.getByTestId("sheet-tab-header").filter({ hasText: label });
|
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);
|
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> {
|
export async function openSheetAsTab(page: Page, label: string): Promise<void> {
|
||||||
await sheetHeader(page, label).getByRole("button", { name: "Open as tab" }).click();
|
await sheetHeader(page, label).getByRole("button", { name: "Open as tab" }).click();
|
||||||
await expect(sheet(page, label)).toHaveCount(0);
|
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);
|
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. */
|
/** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */
|
||||||
export async function tapSheetBackdrop(page: Page): Promise<void> {
|
export async function tapSheetBackdrop(page: Page): Promise<void> {
|
||||||
await page
|
await page
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { startIsolatedHostDaemon } from "./isolated-host-daemon";
|
|||||||
import { seedWorkspace } from "./seed-client";
|
import { seedWorkspace } from "./seed-client";
|
||||||
import { getE2EDaemonPort } from "./daemon-port";
|
import { getE2EDaemonPort } from "./daemon-port";
|
||||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
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 { daemonWsRoutePattern } from "./daemon-port";
|
||||||
import type { SeededWorkspace } from "./seed-client";
|
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 });
|
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> {
|
* On compact the track opens as a sheet, and the chosen row opens in its place. `track` is the
|
||||||
await page.getByTestId("subagents-track-header").click();
|
* pane whose track to open, for when a subagent's sheet shows a track of its own.
|
||||||
await page.getByTestId(`subagents-track-row-${childId}`).click();
|
*/
|
||||||
|
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> {
|
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",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.14.0-beta.1",
|
"version": "0.14.0-beta.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"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"]);
|
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", () => {
|
it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => {
|
||||||
const present = createStreamPresentation();
|
const present = createStreamPresentation();
|
||||||
const timestamp = new Date();
|
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
|
// 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.
|
// retained rows, the latest turn's liveness, or the reader's expanded turns change.
|
||||||
const isLatestTurnOpen =
|
const last = projection.tail.at(-1) ?? null;
|
||||||
(input.isTurnActive || projection.head.length > 0) &&
|
const first = projection.head[0] ?? null;
|
||||||
!isTurnBoundary(projection.tail.at(-1) ?? null, projection.head[0] ?? null) &&
|
const activeTurnIsAnother = Boolean(
|
||||||
!(
|
input.activeTurnId && last?.turnId && input.activeTurnId !== last.turnId,
|
||||||
input.activeTurnId &&
|
);
|
||||||
projection.tail.at(-1)?.turnId &&
|
// Without head rows, the latest retained turn is live only while the active run is that
|
||||||
input.activeTurnId !== projection.tail.at(-1)?.turnId
|
// 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 (
|
if (
|
||||||
!collapsed ||
|
!collapsed ||
|
||||||
collapsedSource !== projection.tail ||
|
collapsedSource !== projection.tail ||
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ import { returnToTimelineTail } from "./timeline-tail-navigation";
|
|||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { ToolCallDetailsContent } from "@/components/tool-call-details";
|
import { ToolCallDetailsContent } from "@/components/tool-call-details";
|
||||||
import { QuestionFormCard } from "@/components/question-form-card";
|
import { QuestionFormCard } from "@/components/question-form-card";
|
||||||
|
import {
|
||||||
|
ToolCallExpansionContext,
|
||||||
|
useAgentToolCallExpansion,
|
||||||
|
useExpandedToolCallGroups,
|
||||||
|
} from "@/tool-calls/expansion-context";
|
||||||
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
|
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
|
||||||
import { createStreamPresentation } from "./presentation";
|
import { createStreamPresentation } from "./presentation";
|
||||||
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
|
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
|
||||||
@@ -364,9 +369,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
|
const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState<Set<string>>(
|
||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState<Set<string>>(
|
const toolCallExpansion = useAgentToolCallExpansion(agentId);
|
||||||
new Set(),
|
const expandedToolCallGroups = useExpandedToolCallGroups(toolCallExpansion);
|
||||||
);
|
|
||||||
const [expandedTurnHostIds, setExpandedTurnHostIds] = useState<ReadonlySet<string>>(new Set());
|
const [expandedTurnHostIds, setExpandedTurnHostIds] = useState<ReadonlySet<string>>(new Set());
|
||||||
|
|
||||||
// Get serverId (fallback to agent's serverId if not provided)
|
// Get serverId (fallback to agent's serverId if not provided)
|
||||||
@@ -431,7 +435,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsNearBottom(true);
|
setIsNearBottom(true);
|
||||||
setExpandedInlineToolCallIds(new Set());
|
setExpandedInlineToolCallIds(new Set());
|
||||||
setExpandedToolCallGroupIds(new Set());
|
|
||||||
setExpandedTurnHostIds(new Set());
|
setExpandedTurnHostIds(new Set());
|
||||||
}, [agentId]);
|
}, [agentId]);
|
||||||
|
|
||||||
@@ -708,17 +711,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none";
|
return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none";
|
||||||
});
|
});
|
||||||
|
|
||||||
const setToolCallGroupExpanded = useCallback((groupId: string, expanded: boolean) => {
|
const setToolCallGroupExpanded = toolCallExpansion.setGroupExpanded;
|
||||||
setExpandedToolCallGroupIds((previous) => {
|
|
||||||
const next = new Set(previous);
|
|
||||||
if (expanded) {
|
|
||||||
next.add(groupId);
|
|
||||||
} else {
|
|
||||||
next.delete(groupId);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const renderUserMessageItem = useCallback(
|
const renderUserMessageItem = useCallback(
|
||||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
|
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
|
||||||
@@ -815,7 +808,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
(
|
(
|
||||||
item: Extract<StreamItem, { kind: "tool_call" }>,
|
item: Extract<StreamItem, { kind: "tool_call" }>,
|
||||||
isLastInSequence: boolean,
|
isLastInSequence: boolean,
|
||||||
maxDetailHeight?: number,
|
options: { maxDetailHeight?: number; standalone?: boolean } = {},
|
||||||
) => {
|
) => {
|
||||||
const { payload } = item;
|
const { payload } = item;
|
||||||
|
|
||||||
@@ -847,7 +840,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
agentMessage={data.agentMessage}
|
agentMessage={data.agentMessage}
|
||||||
isLastInSequence={isLastInSequence}
|
isLastInSequence={isLastInSequence}
|
||||||
onOpenFilePath={handleToolCallOpenFile}
|
onOpenFilePath={handleToolCallOpenFile}
|
||||||
maxDetailHeight={maxDetailHeight}
|
standalone={options.standalone}
|
||||||
|
maxDetailHeight={options.maxDetailHeight}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -863,7 +857,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
status={data.status}
|
status={data.status}
|
||||||
isLastInSequence={isLastInSequence}
|
isLastInSequence={isLastInSequence}
|
||||||
onOpenFilePath={handleToolCallOpenFile}
|
onOpenFilePath={handleToolCallOpenFile}
|
||||||
maxDetailHeight={maxDetailHeight}
|
standalone={options.standalone}
|
||||||
|
maxDetailHeight={options.maxDetailHeight}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -886,7 +881,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
? renderThoughtItem(layoutItem, item)
|
? renderThoughtItem(layoutItem, item)
|
||||||
: renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
|
: renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
|
||||||
}
|
}
|
||||||
const expanded = expandedToolCallGroupIds.has(group.run.id);
|
const expanded = expandedToolCallGroups.has(group.run.id);
|
||||||
return (
|
return (
|
||||||
<OverviewToolCallGroupView
|
<OverviewToolCallGroupView
|
||||||
group={group}
|
group={group}
|
||||||
@@ -898,20 +893,22 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
? group.run.calls.map((call, index) => (
|
? group.run.calls.map((call, index) => (
|
||||||
<React.Fragment key={call.id}>
|
<React.Fragment key={call.id}>
|
||||||
{call.kind === "thought" ? (
|
{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
|
<ThoughtSlot
|
||||||
itemId={call.id}
|
itemId={call.id}
|
||||||
text={call.text}
|
text={call.text}
|
||||||
status={call.status}
|
status={call.status}
|
||||||
isLastInSequence={index === group.run.calls.length - 1}
|
isLastInSequence={index === group.run.calls.length - 1}
|
||||||
defaultExpanded
|
defaultExpanded={false}
|
||||||
|
standalone={false}
|
||||||
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
|
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
renderSingleToolCallItem(
|
renderSingleToolCallItem(call, index === group.run.calls.length - 1, {
|
||||||
call,
|
maxDetailHeight: GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
|
||||||
index === group.run.calls.length - 1,
|
standalone: false,
|
||||||
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
|
})
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))
|
))
|
||||||
@@ -920,7 +917,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
expandedToolCallGroupIds,
|
expandedToolCallGroups,
|
||||||
renderThoughtItem,
|
renderThoughtItem,
|
||||||
setInlineDetailsExpanded,
|
setInlineDetailsExpanded,
|
||||||
getToolCallGroup,
|
getToolCallGroup,
|
||||||
@@ -1172,15 +1169,18 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
() => ({
|
() => ({
|
||||||
contentById: presentation.historyGroupUpdatesByHostId,
|
contentById: presentation.historyGroupUpdatesByHostId,
|
||||||
displayStateById: {
|
displayStateById: {
|
||||||
|
// An opened lone call also marks its future group open; that row stays unrevised
|
||||||
|
// until a group actually forms there.
|
||||||
has: (id: string) =>
|
has: (id: string) =>
|
||||||
expandedToolCallGroupIds.has(id) ||
|
(expandedToolCallGroups.has(id) && getToolCallGroup(id) !== undefined) ||
|
||||||
expandedTurnHostIds.has(id) ||
|
expandedTurnHostIds.has(id) ||
|
||||||
nestedItemIds.has(id),
|
nestedItemIds.has(id),
|
||||||
},
|
},
|
||||||
globalDisplayState: isMobile,
|
globalDisplayState: isMobile,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
expandedToolCallGroupIds,
|
expandedToolCallGroups,
|
||||||
|
getToolCallGroup,
|
||||||
expandedTurnHostIds,
|
expandedTurnHostIds,
|
||||||
isMobile,
|
isMobile,
|
||||||
nestedItemIds,
|
nestedItemIds,
|
||||||
@@ -1205,30 +1205,32 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
<ToolCallSheetProvider>
|
<ToolCallSheetProvider>
|
||||||
<AssistantSelectionCopySurface style={stylesheet.container}>
|
<AssistantSelectionCopySurface style={stylesheet.container}>
|
||||||
<MessageOuterSpacingProvider disableOuterSpacing>
|
<MessageOuterSpacingProvider disableOuterSpacing>
|
||||||
{streamRenderStrategy.render({
|
<ToolCallExpansionContext.Provider value={toolCallExpansion}>
|
||||||
readingRowReplacements: presentation.readingRowReplacements,
|
{streamRenderStrategy.render({
|
||||||
agentId,
|
readingRowReplacements: presentation.readingRowReplacements,
|
||||||
segments: renderModel.segments,
|
agentId,
|
||||||
historyRowRevision,
|
segments: renderModel.segments,
|
||||||
liveHeadRowRevision: expandedToolCallGroupIds,
|
historyRowRevision,
|
||||||
boundary,
|
liveHeadRowRevision: expandedToolCallGroups,
|
||||||
renderers,
|
boundary,
|
||||||
listEmptyComponent,
|
renderers,
|
||||||
viewportRef,
|
listEmptyComponent,
|
||||||
routeBottomAnchorRequest,
|
viewportRef,
|
||||||
isAuthoritativeHistoryReady,
|
routeBottomAnchorRequest,
|
||||||
onNearBottomChange: setIsNearBottom,
|
isAuthoritativeHistoryReady,
|
||||||
onReadingPositionChange: handleReadingPositionChange,
|
onNearBottomChange: setIsNearBottom,
|
||||||
onNearHistoryStart: loadOlder,
|
onReadingPositionChange: handleReadingPositionChange,
|
||||||
isLoadingOlderHistory: isLoadingOlder,
|
onNearHistoryStart: loadOlder,
|
||||||
hasOlderHistory: hasOlder,
|
isLoadingOlderHistory: isLoadingOlder,
|
||||||
olderHistoryProgressKey: progressKey,
|
hasOlderHistory: hasOlder,
|
||||||
scrollEnabled: streamScrollEnabled,
|
olderHistoryProgressKey: progressKey,
|
||||||
listStyle: stylesheet.list,
|
scrollEnabled: streamScrollEnabled,
|
||||||
baseListContentContainerStyle: stylesheet.listContentContainer,
|
listStyle: stylesheet.list,
|
||||||
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
|
baseListContentContainerStyle: stylesheet.listContentContainer,
|
||||||
contentMaxWidth,
|
forwardListContentContainerStyle: stylesheet.forwardListContentContainer,
|
||||||
})}
|
contentMaxWidth,
|
||||||
|
})}
|
||||||
|
</ToolCallExpansionContext.Provider>
|
||||||
</MessageOuterSpacingProvider>
|
</MessageOuterSpacingProvider>
|
||||||
<ChatOutlineRail
|
<ChatOutlineRail
|
||||||
prompts={chatOutline.prompts}
|
prompts={chatOutline.prompts}
|
||||||
@@ -1401,6 +1403,7 @@ interface ThoughtSlotProps {
|
|||||||
status: Extract<StreamItem, { kind: "thought" }>["status"];
|
status: Extract<StreamItem, { kind: "thought" }>["status"];
|
||||||
isLastInSequence: boolean;
|
isLastInSequence: boolean;
|
||||||
defaultExpanded: boolean;
|
defaultExpanded: boolean;
|
||||||
|
standalone?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reasoning text is paced the same way assistant text is; see @/hooks/use-revealed-text.
|
// Reasoning text is paced the same way assistant text is; see @/hooks/use-revealed-text.
|
||||||
@@ -1411,6 +1414,7 @@ function ThoughtSlot({
|
|||||||
status,
|
status,
|
||||||
isLastInSequence,
|
isLastInSequence,
|
||||||
defaultExpanded,
|
defaultExpanded,
|
||||||
|
standalone,
|
||||||
}: ThoughtSlotProps) {
|
}: ThoughtSlotProps) {
|
||||||
const revealedText = useRevealedText(text, status === "ready" ? "complete" : "streaming");
|
const revealedText = useRevealedText(text, status === "ready" ? "complete" : "streaming");
|
||||||
return (
|
return (
|
||||||
@@ -1422,6 +1426,7 @@ function ThoughtSlot({
|
|||||||
status={status === "ready" ? "completed" : "executing"}
|
status={status === "ready" ? "completed" : "executing"}
|
||||||
isLastInSequence={isLastInSequence}
|
isLastInSequence={isLastInSequence}
|
||||||
defaultExpanded={defaultExpanded}
|
defaultExpanded={defaultExpanded}
|
||||||
|
standalone={standalone}
|
||||||
forceInline={defaultExpanded}
|
forceInline={defaultExpanded}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -1436,7 +1441,9 @@ function ToolCallSlot({
|
|||||||
(expanded: boolean) => onInlineDetailsExpandedChangeByItemId(itemId, expanded),
|
(expanded: boolean) => onInlineDetailsExpandedChangeByItemId(itemId, expanded),
|
||||||
[onInlineDetailsExpandedChangeByItemId, itemId],
|
[onInlineDetailsExpandedChangeByItemId, itemId],
|
||||||
);
|
);
|
||||||
return <ToolCall {...rest} onInlineDetailsExpandedChange={handleExpandedChange} />;
|
return (
|
||||||
|
<ToolCall {...rest} itemId={itemId} onInlineDetailsExpandedChange={handleExpandedChange} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
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 { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Keyboard, Pressable, Text, View } from "react-native";
|
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 { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +12,12 @@ import {
|
|||||||
useGlobalWebOverlayLayer,
|
useGlobalWebOverlayLayer,
|
||||||
useWebOverlayRegistration,
|
useWebOverlayRegistration,
|
||||||
} from "../lib/overlay-root";
|
} 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 Animated from "react-native-reanimated";
|
||||||
import { ArrowLeft, Search, X, type LucideIcon } from "lucide-react-native";
|
import { ArrowLeft, Search, X, type LucideIcon } from "lucide-react-native";
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +30,7 @@ import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-shee
|
|||||||
import { ScrollView } from "@/components/ui/scroll-view";
|
import { ScrollView } from "@/components/ui/scroll-view";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
|
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";
|
import { AdaptiveTextInput } from "@/components/adaptive-text-input";
|
||||||
export { AdaptiveTextInput, type AdaptiveTextInputProps } 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.
|
// glyph should line up with the X must reach the same rail.
|
||||||
export const SHEET_HEADER_CLOSE_PADDING_SCALE = 2;
|
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 {
|
export interface SheetHeaderSearch {
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
@@ -244,32 +252,55 @@ const ThemedBottomSheetHandle = withUnistyles(BottomSheetHandle, (theme) => ({
|
|||||||
indicatorStyle: { backgroundColor: theme.colors.palette.zinc[600] },
|
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. */
|
||||||
|
|
||||||
/**
|
|
||||||
* The compact sheet's drag handle: Gorhom's `handleComponent` for every bottom sheet. Children
|
|
||||||
* join the handle, so a header passed here drags the sheet like the indicator does. That is how a
|
|
||||||
* sheet whose content scrolls on its own keeps its header draggable without content panning.
|
|
||||||
*/
|
|
||||||
export function SheetHandle({
|
export function SheetHandle({
|
||||||
children,
|
|
||||||
// Gorhom passes `indicatorStyle={undefined}`, which would override the themed indicator.
|
// Gorhom passes `indicatorStyle={undefined}`, which would override the themed indicator.
|
||||||
indicatorStyle: _indicatorStyle,
|
indicatorStyle: _indicatorStyle,
|
||||||
...props
|
...props
|
||||||
}: ComponentProps<typeof BottomSheetHandle>) {
|
}: ComponentProps<typeof BottomSheetHandle>) {
|
||||||
if (!children) return <ThemedBottomSheetHandle {...props} />;
|
return <ThemedBottomSheetHandle {...props} />;
|
||||||
// The children carry their own semantics; the handle's slider role would swallow them on web.
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
return (
|
||||||
<ThemedBottomSheetHandle
|
<GestureDetector gesture={gesture}>
|
||||||
{...props}
|
<View>{children}</View>
|
||||||
style={HANDLE_WITH_CONTENT_STYLE}
|
</GestureDetector>
|
||||||
accessible={false}
|
);
|
||||||
accessibilityRole={null}
|
}
|
||||||
accessibilityLabel={null}
|
|
||||||
accessibilityHint={null}
|
/**
|
||||||
>
|
* A compact sheet's content area that reaches the bottom of the screen. It owns the system
|
||||||
{children}
|
* navigation inset: it pads it once and tells its content none is left, so content that pads the
|
||||||
</ThemedBottomSheetHandle>
|
* 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;
|
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({
|
export function AdaptiveModalSheet({
|
||||||
header,
|
header,
|
||||||
visible,
|
visible,
|
||||||
@@ -508,34 +609,11 @@ export function AdaptiveModalSheet({
|
|||||||
}: AdaptiveModalSheetProps) {
|
}: AdaptiveModalSheetProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const isKeyboardVisible = useKeyboardVisibility(visible);
|
const isKeyboardVisible = useKeyboardVisibility(visible);
|
||||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
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(
|
const footerView = useMemo(
|
||||||
() =>
|
() => (footer ? <View style={[styles.footer, footerContainerStyle]}>{footer}</View> : null),
|
||||||
footer ? (
|
[footer, footerContainerStyle],
|
||||||
<View style={footerClearanceStyle}>
|
|
||||||
<View style={[styles.footer, footerContainerStyle]}>{footer}</View>
|
|
||||||
</View>
|
|
||||||
) : null,
|
|
||||||
[footer, footerClearanceStyle, footerContainerStyle],
|
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWeb && visible) {
|
if (!isWeb && visible) {
|
||||||
@@ -614,32 +692,6 @@ export function AdaptiveModalSheet({
|
|||||||
}, [visible, isMobile, onDismiss, shouldRenderWeb]);
|
}, [visible, isMobile, onDismiss, shouldRenderWeb]);
|
||||||
|
|
||||||
if (isMobile) {
|
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 (
|
return (
|
||||||
<IsolatedBottomSheetModal
|
<IsolatedBottomSheetModal
|
||||||
ref={sheetRef}
|
ref={sheetRef}
|
||||||
@@ -658,7 +710,18 @@ export function AdaptiveModalSheet({
|
|||||||
accessible={false}
|
accessible={false}
|
||||||
presentation={presentation}
|
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>
|
</IsolatedBottomSheetModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ vi.mock("react-i18next", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("react-native-safe-area-context", () => ({
|
vi.mock("react-native-safe-area-context", () => ({
|
||||||
|
SafeAreaProvider: ({ children }: { children?: React.ReactNode }) => children,
|
||||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
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 { Modal, Pressable, Text, View } from "react-native";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
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 { X } from "lucide-react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { AttachmentMetadata } from "@/attachments/types";
|
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) {
|
export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const metadata = source?.type === "attachment" ? source.metadata : null;
|
const metadata = source?.type === "attachment" ? source.metadata : null;
|
||||||
const attachmentUrl = useAttachmentPreviewUrl(metadata);
|
const attachmentUrl = useAttachmentPreviewUrl(metadata);
|
||||||
const url = source?.type === "uri" ? source.uri : attachmentUrl;
|
const url = source?.type === "uri" ? source.uri : attachmentUrl;
|
||||||
@@ -56,18 +55,6 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
|||||||
onKeyDown: handleWebOverlayKeyDown,
|
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(
|
const actions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -91,17 +78,19 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
|||||||
return (
|
return (
|
||||||
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
||||||
<ModalRoot style={styles.root}>
|
<ModalRoot style={styles.root}>
|
||||||
<WindowChromeRootRegion corners="both">
|
{/* The modal is its own window: its insets are the window's, not the ones left where the
|
||||||
<View ref={setWebOverlayScope} style={styles.root}>
|
image was opened (inside a sheet tab, the bottom one is already spent). */}
|
||||||
<Pressable
|
<SafeAreaProvider>
|
||||||
testID="attachment-lightbox-backdrop"
|
<WindowChromeRootRegion corners="both">
|
||||||
accessibilityRole="button"
|
<View ref={setWebOverlayScope} style={styles.root}>
|
||||||
accessibilityLabel={t("message.attachments.dismissImage")}
|
<Pressable
|
||||||
onPress={onClose}
|
testID="attachment-lightbox-backdrop"
|
||||||
style={styles.backdrop}
|
accessibilityRole="button"
|
||||||
/>
|
accessibilityLabel={t("message.attachments.dismissImage")}
|
||||||
<View pointerEvents="box-none" style={contentLayerStyle}>
|
onPress={onClose}
|
||||||
<View pointerEvents="box-none" style={styles.imageArea}>
|
style={styles.backdrop}
|
||||||
|
/>
|
||||||
|
<LightboxImageArea>
|
||||||
{hasError ? (
|
{hasError ? (
|
||||||
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
|
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
|
||||||
) : (
|
) : (
|
||||||
@@ -117,15 +106,38 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
|||||||
uri={url}
|
uri={url}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</LightboxImageArea>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</WindowChromeRootRegion>
|
||||||
</WindowChromeRootRegion>
|
</SafeAreaProvider>
|
||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
</Modal>
|
</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) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
root: {
|
root: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
|
|||||||
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
|
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
|
||||||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||||||
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
|
||||||
|
import { useToolCallExpanded } from "@/tool-calls/expansion-context";
|
||||||
import { useTargetAgentTitle } from "@/tool-calls/use-target-agent-title";
|
import { useTargetAgentTitle } from "@/tool-calls/use-target-agent-title";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||||
@@ -2241,6 +2242,9 @@ interface ToolCallProps {
|
|||||||
defaultExpanded?: boolean;
|
defaultExpanded?: boolean;
|
||||||
forceInline?: boolean;
|
forceInline?: boolean;
|
||||||
maxDetailHeight?: number;
|
maxDetailHeight?: number;
|
||||||
|
itemId: string;
|
||||||
|
/** Shown as its own row rather than inside a tool call group. */
|
||||||
|
standalone?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ToolCall = memo(function ToolCall({
|
export const ToolCall = memo(function ToolCall({
|
||||||
@@ -2262,9 +2266,15 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
defaultExpanded,
|
defaultExpanded,
|
||||||
forceInline = false,
|
forceInline = false,
|
||||||
maxDetailHeight = 400,
|
maxDetailHeight = 400,
|
||||||
|
itemId,
|
||||||
|
standalone = true,
|
||||||
}: ToolCallProps) {
|
}: ToolCallProps) {
|
||||||
const { openToolCall } = useToolCallSheet();
|
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 isMobile = useIsCompactFormFactor();
|
||||||
const shouldRenderInline = !isMobile || forceInline;
|
const shouldRenderInline = !isMobile || forceInline;
|
||||||
@@ -2319,10 +2329,11 @@ export const ToolCall = memo(function ToolCall({
|
|||||||
showLoadingSkeleton: presentation.isLoadingDetails,
|
showLoadingSkeleton: presentation.isLoadingDetails,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setIsExpanded((prev) => !prev);
|
toggleExpanded();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
shouldRenderInline,
|
shouldRenderInline,
|
||||||
|
toggleExpanded,
|
||||||
openToolCall,
|
openToolCall,
|
||||||
toolName,
|
toolName,
|
||||||
presentation.detail,
|
presentation.detail,
|
||||||
@@ -2428,5 +2439,7 @@ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) {
|
|||||||
if (previous.defaultExpanded !== next.defaultExpanded) return false;
|
if (previous.defaultExpanded !== next.defaultExpanded) return false;
|
||||||
if (previous.forceInline !== next.forceInline) return false;
|
if (previous.forceInline !== next.forceInline) return false;
|
||||||
if (previous.maxDetailHeight !== next.maxDetailHeight) 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1692,7 +1692,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
opacity: 0,
|
opacity: 0,
|
||||||
},
|
},
|
||||||
emptyState: {
|
emptyState: {
|
||||||
paddingVertical: theme.spacing[4],
|
padding: theme.spacing[4],
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ export function Button({
|
|||||||
trailing,
|
trailing,
|
||||||
style,
|
style,
|
||||||
textStyle,
|
textStyle,
|
||||||
|
numberOfLines,
|
||||||
disabled,
|
disabled,
|
||||||
loading = false,
|
loading = false,
|
||||||
accessibilityRole,
|
accessibilityRole,
|
||||||
@@ -182,6 +183,7 @@ export function Button({
|
|||||||
trailing?: ReactNode;
|
trailing?: ReactNode;
|
||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
textStyle?: StyleProp<TextStyle>;
|
textStyle?: StyleProp<TextStyle>;
|
||||||
|
numberOfLines?: number;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
>) {
|
>) {
|
||||||
@@ -277,7 +279,11 @@ export function Button({
|
|||||||
iconSize={buttonIconSize[size]}
|
iconSize={buttonIconSize[size]}
|
||||||
uniProps={resolveIconMapping()}
|
uniProps={resolveIconMapping()}
|
||||||
/>
|
/>
|
||||||
{children != null ? <Text style={resolvedTextStyle}>{children}</Text> : null}
|
{children != null ? (
|
||||||
|
<Text numberOfLines={numberOfLines} style={resolvedTextStyle}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
{trailing}
|
{trailing}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -938,7 +938,6 @@ interface MobileBodyProps {
|
|||||||
handleSelect: (id: string) => void;
|
handleSelect: (id: string) => void;
|
||||||
renderOption: RenderOptionFn | undefined;
|
renderOption: RenderOptionFn | undefined;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
safeAreaBottom: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileComboboxBody(props: MobileBodyProps): ReactElement | null {
|
function MobileComboboxBody(props: MobileBodyProps): ReactElement | null {
|
||||||
@@ -961,21 +960,6 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
|
|||||||
() => [styles.comboboxTitle, { color: props.titleColor }],
|
() => [styles.comboboxTitle, { color: props.titleColor }],
|
||||||
[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 ? (
|
const body = props.hasChildren ? (
|
||||||
props.children
|
props.children
|
||||||
) : (
|
) : (
|
||||||
@@ -1006,51 +990,74 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
|
|||||||
keyboardBlurBehavior="none"
|
keyboardBlurBehavior="none"
|
||||||
presentation={props.presentation}
|
presentation={props.presentation}
|
||||||
>
|
>
|
||||||
<SheetVisibleFrame footer={footerView}>
|
<ComboboxSheetFrame footer={props.footer}>
|
||||||
<View style={frameStyle}>
|
{props.header ? (
|
||||||
{props.header ? (
|
<SheetHeaderView header={props.header} onClose={props.onClose} />
|
||||||
<SheetHeaderView header={props.header} onClose={props.onClose} />
|
) : (
|
||||||
) : (
|
<>
|
||||||
<>
|
<View style={styles.bottomSheetHeader}>
|
||||||
<View style={styles.bottomSheetHeader}>
|
<Text key={props.titleColor} style={comboboxTitleStyle}>
|
||||||
<Text key={props.titleColor} style={comboboxTitleStyle}>
|
{props.title}
|
||||||
{props.title}
|
</Text>
|
||||||
</Text>
|
</View>
|
||||||
</View>
|
{props.stickyHeader}
|
||||||
{props.stickyHeader}
|
{!props.hasChildren && props.searchable ? (
|
||||||
{!props.hasChildren && props.searchable ? (
|
<SearchInput
|
||||||
<SearchInput
|
placeholder={props.searchPlaceholder}
|
||||||
placeholder={props.searchPlaceholder}
|
onChangeText={props.setSearchQueryWithCallback}
|
||||||
onChangeText={props.setSearchQueryWithCallback}
|
onSubmitEditing={props.handleSubmitSearch}
|
||||||
onSubmitEditing={props.handleSubmitSearch}
|
autoFocus={false}
|
||||||
autoFocus={false}
|
useBottomSheetInput
|
||||||
useBottomSheetInput
|
resetKey={props.searchResetKey}
|
||||||
resetKey={props.searchResetKey}
|
/>
|
||||||
/>
|
) : null}
|
||||||
) : null}
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
|
||||||
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
|
body
|
||||||
body
|
) : (
|
||||||
) : (
|
<BottomSheetScrollView
|
||||||
<BottomSheetScrollView
|
style={styles.mobileSheetBody}
|
||||||
style={styles.mobileSheetBody}
|
contentContainerStyle={[
|
||||||
contentContainerStyle={[
|
styles.comboboxScrollContent,
|
||||||
styles.comboboxScrollContent,
|
props.mobileChildrenContentContainerStyle,
|
||||||
props.mobileChildrenContentContainerStyle,
|
]}
|
||||||
]}
|
keyboardShouldPersistTaps="handled"
|
||||||
keyboardShouldPersistTaps="handled"
|
showsVerticalScrollIndicator={false}
|
||||||
showsVerticalScrollIndicator={false}
|
>
|
||||||
>
|
{body}
|
||||||
{body}
|
</BottomSheetScrollView>
|
||||||
</BottomSheetScrollView>
|
)}
|
||||||
)}
|
</ComboboxSheetFrame>
|
||||||
</View>
|
|
||||||
</SheetVisibleFrame>
|
|
||||||
</IsolatedBottomSheetModal>
|
</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 {
|
interface DesktopBodyProps {
|
||||||
overlayLayer: number;
|
overlayLayer: number;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -1327,7 +1334,6 @@ export function Combobox({
|
|||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const active = useRetainedPanelActive();
|
const active = useRetainedPanelActive();
|
||||||
const floatingLayer = useOverlayLayer("floating");
|
const floatingLayer = useOverlayLayer("floating");
|
||||||
const safeAreaInsets = useSafeAreaInsets();
|
|
||||||
const titleColor = theme.colors.foreground;
|
const titleColor = theme.colors.foreground;
|
||||||
const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition);
|
const effectiveOptionsPosition = resolveEffectiveOptionsPosition(isMobile, optionsPosition);
|
||||||
const isDesktopAboveSearch = resolveIsDesktopAboveSearch(isMobile, effectiveOptionsPosition);
|
const isDesktopAboveSearch = resolveIsDesktopAboveSearch(isMobile, effectiveOptionsPosition);
|
||||||
@@ -1608,7 +1614,6 @@ export function Combobox({
|
|||||||
emptyText={resolvedEmptyText}
|
emptyText={resolvedEmptyText}
|
||||||
handleSelect={handleSelect}
|
handleSelect={handleSelect}
|
||||||
renderOption={renderOption}
|
renderOption={renderOption}
|
||||||
safeAreaBottom={safeAreaInsets.bottom}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</MobileComboboxBody>
|
</MobileComboboxBody>
|
||||||
|
|||||||
@@ -123,19 +123,25 @@ export function useIsolatedBottomSheetVisibility({
|
|||||||
visible,
|
visible,
|
||||||
isEnabled,
|
isEnabled,
|
||||||
onClose,
|
onClose,
|
||||||
|
onBack,
|
||||||
}: {
|
}: {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
isEnabled?: boolean;
|
isEnabled?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/** Offered Android Back before it closes the sheet; returns true when it handled the press. */
|
||||||
|
onBack?: () => boolean;
|
||||||
}) {
|
}) {
|
||||||
const onCloseRef = useRef(onClose);
|
const onCloseRef = useRef(onClose);
|
||||||
onCloseRef.current = onClose;
|
onCloseRef.current = onClose;
|
||||||
|
const onBackRef = useRef(onBack);
|
||||||
|
onBackRef.current = onBack;
|
||||||
|
|
||||||
const tracker = useMemo(
|
const tracker = useMemo(
|
||||||
() =>
|
() =>
|
||||||
createBottomSheetVisibilityTracker({
|
createBottomSheetVisibilityTracker({
|
||||||
onClose: () => onCloseRef.current(),
|
onClose: () => onCloseRef.current(),
|
||||||
backPress: systemBackPress,
|
backPress: systemBackPress,
|
||||||
|
onBack: () => onBackRef.current?.() ?? false,
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -204,6 +204,31 @@ describe("bottom sheet visibility tracker", () => {
|
|||||||
expect(context.sheet.events).toEqual([{ type: "present" }, { type: "dismiss" }]);
|
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", () => {
|
it("leaves Back to the navigator before the sheet is on screen and after it closes", () => {
|
||||||
const context = setup();
|
const context = setup();
|
||||||
context.tracker.attachController(context.sheet);
|
context.tracker.attachController(context.sheet);
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ type BottomSheetPhase = "closed" | "presenting" | "presented" | "dismissing";
|
|||||||
export function createBottomSheetVisibilityTracker(opts: {
|
export function createBottomSheetVisibilityTracker(opts: {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
backPress?: BackPressSource;
|
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 {
|
}): BottomSheetVisibilityTracker {
|
||||||
let controller: BottomSheetController | null = null;
|
let controller: BottomSheetController | null = null;
|
||||||
let visible = false;
|
let visible = false;
|
||||||
@@ -60,6 +65,7 @@ export function createBottomSheetVisibilityTracker(opts: {
|
|||||||
}
|
}
|
||||||
releaseBackPress =
|
releaseBackPress =
|
||||||
opts.backPress?.subscribe(() => {
|
opts.backPress?.subscribe(() => {
|
||||||
|
if (opts.onBack?.()) return true;
|
||||||
dismiss();
|
dismiss();
|
||||||
return true;
|
return true;
|
||||||
}) ?? null;
|
}) ?? null;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export { MenuRoot, MenuTrigger, type MenuTriggerProps, type MenuTriggerState } f
|
|||||||
export {
|
export {
|
||||||
MenuSurface,
|
MenuSurface,
|
||||||
useMenuSurface,
|
useMenuSurface,
|
||||||
|
type MenuFillPageDefinition,
|
||||||
|
type MenuListPageDefinition,
|
||||||
type MenuPageDefinition,
|
type MenuPageDefinition,
|
||||||
type MenuSurfaceProps,
|
type MenuSurfaceProps,
|
||||||
} from "./menu-surface";
|
} from "./menu-surface";
|
||||||
|
|||||||
@@ -61,3 +61,38 @@ describe("useMenuState selectItem elsewhere", () => {
|
|||||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
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,
|
goBack as goBackPath,
|
||||||
MENU_ROOT_PATH,
|
MENU_ROOT_PATH,
|
||||||
openSubPage,
|
openSubPage,
|
||||||
|
pruneMissingPages,
|
||||||
type MenuPath,
|
type MenuPath,
|
||||||
} from "./menu-navigation";
|
} from "./menu-navigation";
|
||||||
import type { Rect } from "../anchor";
|
import type { Rect } from "../anchor";
|
||||||
@@ -40,7 +41,10 @@ export interface MenuContextValue {
|
|||||||
path: MenuPath;
|
path: MenuPath;
|
||||||
openSub: (sub: { id: string; depth: number }) => void;
|
openSub: (sub: { id: string; depth: number }) => void;
|
||||||
closeSub: (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);
|
const MenuContext = createContext<MenuContextValue | null>(null);
|
||||||
@@ -160,7 +164,13 @@ export function useMenuState({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const goBack = useCallback(() => {
|
const goBack = useCallback(() => {
|
||||||
|
if (path.length === 0) return false;
|
||||||
setPath((current) => goBackPath(current));
|
setPath((current) => goBackPath(current));
|
||||||
|
return true;
|
||||||
|
}, [path.length]);
|
||||||
|
|
||||||
|
const prunePages = useCallback((hasPage: (id: string) => boolean) => {
|
||||||
|
setPath((current) => pruneMissingPages(current, hasPage));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
@@ -176,7 +186,19 @@ export function useMenuState({
|
|||||||
openSub,
|
openSub,
|
||||||
closeSub,
|
closeSub,
|
||||||
goBack,
|
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,
|
isSubPageOpen,
|
||||||
MENU_ROOT_PATH,
|
MENU_ROOT_PATH,
|
||||||
openSubPage,
|
openSubPage,
|
||||||
|
pruneMissingPages,
|
||||||
} from "./menu-navigation";
|
} from "./menu-navigation";
|
||||||
|
|
||||||
describe("openSubPage", () => {
|
describe("openSubPage", () => {
|
||||||
@@ -73,3 +74,15 @@ describe("isSubPageOpen", () => {
|
|||||||
expect(isSubPageOpen(path, { id: "grouping", depth: 0 })).toBe(false);
|
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);
|
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 {
|
export function goBack(path: MenuPath): MenuPath {
|
||||||
if (path.length === 0) return path;
|
if (path.length === 0) return path;
|
||||||
return path.slice(0, -1);
|
return path.slice(0, -1);
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ import {
|
|||||||
useIsolatedBottomSheetVisibility,
|
useIsolatedBottomSheetVisibility,
|
||||||
type ContextBridge,
|
type ContextBridge,
|
||||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
} 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 { SPACING, type Theme } from "@/styles/theme";
|
||||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
import { useMenuContext, MenuContextProvider } from "./menu-context";
|
import { useMenuContext, MenuContextProvider } from "./menu-context";
|
||||||
@@ -32,24 +37,6 @@ import type { KeyboardFocusScope } from "@/keyboard/actions";
|
|||||||
const ThemedChevronLeft = withUnistyles(ChevronLeft);
|
const ThemedChevronLeft = withUnistyles(ChevronLeft);
|
||||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
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. */
|
/** How long the pointer must rest on a submenu row before its flyout opens. */
|
||||||
const HOVER_OPEN_DELAY_MS = 90;
|
const HOVER_OPEN_DELAY_MS = 90;
|
||||||
/**
|
/**
|
||||||
@@ -65,7 +52,7 @@ const HOVER_CLOSE_GRACE_MS = 260;
|
|||||||
*/
|
*/
|
||||||
const SUBMENU_OVERLAP = 5;
|
const SUBMENU_OVERLAP = 5;
|
||||||
|
|
||||||
export interface MenuPageDefinition {
|
export interface MenuListPageDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
content: ReactNode;
|
content: ReactNode;
|
||||||
@@ -78,6 +65,24 @@ export interface MenuPageDefinition {
|
|||||||
* surface stops closing on hover, since its parent leads back to the same dismissal.
|
* surface stops closing on hover, since its parent leads back to the same dismissal.
|
||||||
*/
|
*/
|
||||||
hoverIntent?: boolean;
|
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 {
|
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.
|
* popover (plus one flyout per open submenu) or a bottom sheet showing one page at a time.
|
||||||
*/
|
*/
|
||||||
export function MenuSurface(props: MenuSurfaceProps): ReactElement | null {
|
export function MenuSurface(props: MenuSurfaceProps): ReactElement | null {
|
||||||
const { presentation, open, setOpen } = useMenuContext("MenuSurface");
|
const { presentation, open, setOpen, path, prunePages } = useMenuContext("MenuSurface");
|
||||||
const active = useRetainedPanelActive();
|
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
|
// 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
|
// 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.
|
// 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
|
// `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.
|
// its trigger, and while it is open nothing on this surface closes on a pointer leaving it.
|
||||||
const hoverValue = useMemo<MenuSurfaceContextValue>(() => {
|
const hoverValue = useMemo<MenuSurfaceContextValue>(() => {
|
||||||
const locked = openPages.some(({ page }) => page.hoverIntent === false);
|
const locked = openPages.some(({ page }) => !pageHoverIntent(page));
|
||||||
return {
|
return {
|
||||||
...surfaceValue,
|
...surfaceValue,
|
||||||
hoverOpen: (sub) => {
|
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);
|
surfaceValue.hoverOpen(sub);
|
||||||
},
|
},
|
||||||
hoverClose: (depth) => {
|
hoverClose: (depth) => {
|
||||||
@@ -353,17 +365,6 @@ function MenuSheetSurface({
|
|||||||
const { height: windowHeight } = useWindowDimensions();
|
const { height: windowHeight } = useWindowDimensions();
|
||||||
const menu = useMenuContext("MenuSurface");
|
const menu = useMenuContext("MenuSurface");
|
||||||
const { value: surfaceValue } = useSubAnchors();
|
const { value: surfaceValue } = useSubAnchors();
|
||||||
const safeAreaInsets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const sheetScrollContentStyle = useMemo(
|
|
||||||
() => ({
|
|
||||||
paddingBottom: getMenuSheetBottomPadding({
|
|
||||||
safeAreaBottom: safeAreaInsets.bottom,
|
|
||||||
breathingRoom: SPACING[1],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
[safeAreaInsets.bottom],
|
|
||||||
);
|
|
||||||
const sheetDataSet = useMemo(
|
const sheetDataSet = useMemo(
|
||||||
() => (keyboardFocusScope ? { keyboardScope: keyboardFocusScope } : undefined),
|
() => (keyboardFocusScope ? { keyboardScope: keyboardFocusScope } : undefined),
|
||||||
[keyboardFocusScope],
|
[keyboardFocusScope],
|
||||||
@@ -374,11 +375,15 @@ function MenuSheetSurface({
|
|||||||
visible: menu.open,
|
visible: menu.open,
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
onClose: handleClose,
|
onClose: handleClose,
|
||||||
|
// Android Back steps back through the pages before it closes the sheet.
|
||||||
|
onBack: menu.goBack,
|
||||||
});
|
});
|
||||||
|
|
||||||
const openPageId = currentPageId(menu.path);
|
const openPageId = currentPageId(menu.path);
|
||||||
const openPage = openPageId ? pages.find((page) => page.id === openPageId) : null;
|
const openPage = openPageId ? pages.find((page) => page.id === openPageId) : null;
|
||||||
const depth = menu.path.length;
|
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
|
// 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
|
// rebuilt on the other side. Providing them around the modal instead would put them on the
|
||||||
@@ -393,12 +398,18 @@ function MenuSheetSurface({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemedBottomSheetModal
|
<IsolatedBottomSheetModal
|
||||||
ref={sheetRef}
|
ref={sheetRef}
|
||||||
contextBridge={contextBridge}
|
contextBridge={contextBridge}
|
||||||
|
backgroundComponent={SheetBackground}
|
||||||
|
handleComponent={SheetHandle}
|
||||||
// Content-sized rather than fixed snap points: a pushed page is rarely the same height
|
// 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.
|
// 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}
|
maxDynamicContentSize={windowHeight * 0.8}
|
||||||
onChange={handleSheetChange}
|
onChange={handleSheetChange}
|
||||||
onDismiss={handleSheetDismiss}
|
onDismiss={handleSheetDismiss}
|
||||||
@@ -412,28 +423,64 @@ function MenuSheetSurface({
|
|||||||
keyboardBehavior="interactive"
|
keyboardBehavior="interactive"
|
||||||
keyboardBlurBehavior="restore"
|
keyboardBlurBehavior="restore"
|
||||||
>
|
>
|
||||||
<BottomSheetScrollView
|
{fillPage ? (
|
||||||
dataSet={sheetDataSet}
|
<View style={styles.fillPage} testID={testID ? `${testID}-${fillPage.id}` : undefined}>
|
||||||
contentContainerStyle={sheetScrollContentStyle}
|
{fillPage.content}
|
||||||
keyboardShouldPersistTaps="handled"
|
</View>
|
||||||
showsVerticalScrollIndicator={false}
|
) : (
|
||||||
testID={testID ? `${testID}-content` : undefined}
|
<MenuSheetScrollView dataSet={sheetDataSet} testID={testID}>
|
||||||
>
|
{listPage ? (
|
||||||
{openPage ? (
|
<>
|
||||||
<>
|
<MenuSheetHeader title={listPage.title} onBack={menu.goBack} />
|
||||||
<MenuSheetHeader title={openPage.title} onBack={menu.goBack} />
|
<MenuPage depth={depth}>{listPage.content}</MenuPage>
|
||||||
<MenuPage depth={depth}>{openPage.content}</MenuPage>
|
</>
|
||||||
</>
|
) : (
|
||||||
) : (
|
<>
|
||||||
<>
|
{sheetTitle ? (
|
||||||
{sheetTitle ? (
|
<MenuSheetHeader title={sheetTitle} onBack={null} trailing={sheetTrailing} />
|
||||||
<MenuSheetHeader title={sheetTitle} onBack={null} trailing={sheetTrailing} />
|
) : null}
|
||||||
) : null}
|
<MenuPage depth={0}>{children}</MenuPage>
|
||||||
<MenuPage depth={0}>{children}</MenuPage>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
</MenuSheetScrollView>
|
||||||
</BottomSheetScrollView>
|
)}
|
||||||
</ThemedBottomSheetModal>
|
</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) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
fillPage: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
sheetHeader: {
|
sheetHeader: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useMemo, type ReactNode } from "react";
|
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 { StyleSheet } from "react-native-unistyles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
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 { useProviderIcon } from "@/components/provider-icons";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
const PREVIEW_HEIGHT = 240;
|
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 }) {
|
export function MessagePreview({ children }: { children: ReactNode }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [height, setHeight] = useState(0);
|
const [overflowing, setOverflowing] = useState(false);
|
||||||
const overflowing = height > PREVIEW_HEIGHT;
|
|
||||||
const measure = useCallback(
|
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 toggle = useCallback(() => setExpanded((value) => !value), []);
|
||||||
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
|
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<View style={!expanded && styles.preview}>
|
<View style={!expanded && previewStyle}>
|
||||||
<View style={styles.body} onLayout={measure}>
|
<View style={styles.body} onLayout={measure}>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
@@ -74,6 +80,8 @@ export function MessageSender({
|
|||||||
size="sm"
|
size="sm"
|
||||||
leftIcon={renderProviderIcon}
|
leftIcon={renderProviderIcon}
|
||||||
style={styles.sender}
|
style={styles.sender}
|
||||||
|
textStyle={styles.senderText}
|
||||||
|
numberOfLines={1}
|
||||||
disabled={!host}
|
disabled={!host}
|
||||||
onPress={openSender}
|
onPress={openSender}
|
||||||
>
|
>
|
||||||
@@ -83,9 +91,9 @@ export function MessageSender({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
preview: { maxHeight: PREVIEW_HEIGHT, overflow: "hidden" },
|
|
||||||
body: { flexShrink: 0 },
|
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 },
|
toggle: { alignSelf: "flex-start", paddingHorizontal: 0, minHeight: 0, borderWidth: 0 },
|
||||||
expandedToggle: { marginTop: theme.spacing[2] },
|
expandedToggle: { marginTop: theme.spacing[2] },
|
||||||
fade: { position: "absolute", bottom: 0, left: 0, right: 0, height: theme.spacing[12] },
|
fade: { position: "absolute", bottom: 0, left: 0, right: 0, height: theme.spacing[12] },
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
useWorkspaceAttachmentsStore,
|
useWorkspaceAttachmentsStore,
|
||||||
} from "@/attachments/workspace-attachments-store";
|
} from "@/attachments/workspace-attachments-store";
|
||||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
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 { isWeb } from "@/constants/platform";
|
||||||
import {
|
import {
|
||||||
buildWorkspaceTabPersistenceKey,
|
buildWorkspaceTabPersistenceKey,
|
||||||
@@ -390,7 +390,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
const editingSession = useComposerEditingSession("chat");
|
const editingSession = useComposerEditingSession("chat");
|
||||||
const isComposerFullscreen = useComposerFullscreen(editingSession);
|
const isComposerFullscreen = useComposerFullscreen(editingSession);
|
||||||
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
|
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
|
||||||
COMPACT_FORM_FACTOR_WIDTH,
|
COMPACT_CONTENT_WIDTH,
|
||||||
{ initialIsBelow: isCompactFormFactor },
|
{ initialIsBelow: isCompactFormFactor },
|
||||||
);
|
);
|
||||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
MenuSurface,
|
MenuSurface,
|
||||||
MenuTrigger,
|
MenuTrigger,
|
||||||
useMenuContext,
|
useMenuContext,
|
||||||
|
useMenuDepth,
|
||||||
|
type MenuPageDefinition,
|
||||||
type MenuTriggerState,
|
type MenuTriggerState,
|
||||||
} from "@/components/ui/menu";
|
} from "@/components/ui/menu";
|
||||||
import { StatusRing } from "@/components/status-ring";
|
import { StatusRing } from "@/components/status-ring";
|
||||||
@@ -61,6 +63,8 @@ export interface ComposerTrackPillProps {
|
|||||||
accessibilityLabel?: string;
|
accessibilityLabel?: string;
|
||||||
/** Panel body. Rendered into a popover on wide screens and a sheet on compact ones. */
|
/** Panel body. Rendered into a popover on wide screens and a sheet on compact ones. */
|
||||||
children: ReactNode;
|
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,
|
testID,
|
||||||
accessibilityLabel,
|
accessibilityLabel,
|
||||||
children,
|
children,
|
||||||
|
pages,
|
||||||
}: ComposerTrackPillProps): ReactElement {
|
}: ComposerTrackPillProps): ReactElement {
|
||||||
return (
|
return (
|
||||||
<MenuRoot compactMode="sheet">
|
<MenuRoot compactMode="sheet">
|
||||||
@@ -98,6 +103,7 @@ export function ComposerTrackPill({
|
|||||||
align="start"
|
align="start"
|
||||||
offset={PANEL_OFFSET}
|
offset={PANEL_OFFSET}
|
||||||
sheetTitle={panelTitle}
|
sheetTitle={panelTitle}
|
||||||
|
pages={pages}
|
||||||
minWidth={PANEL_MIN_WIDTH}
|
minWidth={PANEL_MIN_WIDTH}
|
||||||
maxWidth={PANEL_MAX_WIDTH}
|
maxWidth={PANEL_MAX_WIDTH}
|
||||||
maxHeight={PANEL_MAX_HEIGHT}
|
maxHeight={PANEL_MAX_HEIGHT}
|
||||||
@@ -199,6 +205,11 @@ export interface ComposerTrackRowProps {
|
|||||||
* lands inside the panel is not.
|
* lands inside the panel is not.
|
||||||
*/
|
*/
|
||||||
closeOnSelect?: boolean;
|
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;
|
disabled?: boolean;
|
||||||
accessibilityLabel?: string;
|
accessibilityLabel?: string;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
@@ -220,18 +231,23 @@ export function ComposerTrackRow({
|
|||||||
children,
|
children,
|
||||||
onPress,
|
onPress,
|
||||||
closeOnSelect = true,
|
closeOnSelect = true,
|
||||||
|
opensPage,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
accessibilityLabel,
|
accessibilityLabel,
|
||||||
testID,
|
testID,
|
||||||
}: ComposerTrackRowProps): ReactElement {
|
}: ComposerTrackRowProps): ReactElement {
|
||||||
const { selectItem } = useMenuContext("ComposerTrackRow");
|
const { selectItem, openSub } = useMenuContext("ComposerTrackRow");
|
||||||
|
const depth = useMenuDepth();
|
||||||
const [hovered, setHovered] = useState(false);
|
const [hovered, setHovered] = useState(false);
|
||||||
const handlePointerEnter = useCallback(() => setHovered(true), []);
|
const handlePointerEnter = useCallback(() => setHovered(true), []);
|
||||||
const handlePointerLeave = useCallback(() => setHovered(false), []);
|
const handlePointerLeave = useCallback(() => setHovered(false), []);
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(() => {
|
||||||
() => selectItem(onPress, closeOnSelect),
|
if (opensPage) {
|
||||||
[closeOnSelect, onPress, selectItem],
|
selectItem(() => openSub({ id: opensPage, depth }), false);
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
selectItem(onPress, closeOnSelect);
|
||||||
|
}, [closeOnSelect, depth, onPress, openSub, opensPage, selectItem]);
|
||||||
|
|
||||||
const itemDataSet = useMemo(
|
const itemDataSet = useMemo(
|
||||||
() => ({ menuItem: "true", menuDisabled: disabled ? "true" : "false" }),
|
() => ({ menuItem: "true", menuDisabled: disabled ? "true" : "false" }),
|
||||||
@@ -251,7 +267,7 @@ export function ComposerTrackRow({
|
|||||||
[hovered, renderRow],
|
[hovered, renderRow],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!onPress) {
|
if (!onPress && !opensPage) {
|
||||||
return <View accessibilityLabel={accessibilityLabel}>{renderRow(false)}</View>;
|
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 WORKSPACE_SECONDARY_HEADER_HEIGHT = 36;
|
||||||
export const HEADER_TOP_PADDING_MOBILE = 8;
|
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
|
// Settings navigation can shrink below its preferred width to preserve the detail
|
||||||
// detail target must fit together before it can share width with app navigation.
|
// minimum. Both targets must fit before Settings shares width with app navigation.
|
||||||
export const SETTINGS_DESKTOP_SIDEBAR_WIDTH = 320;
|
export const SETTINGS_DESKTOP_SIDEBAR_WIDTH = 320;
|
||||||
export const SETTINGS_DESKTOP_DETAIL_MIN_WIDTH = 400;
|
export const SETTINGS_DESKTOP_DETAIL_MIN_WIDTH = 400;
|
||||||
export const SETTINGS_DESKTOP_SPLIT_MIN_WIDTH =
|
export const SETTINGS_DESKTOP_SPLIT_MIN_WIDTH =
|
||||||
@@ -33,7 +34,7 @@ export {
|
|||||||
getIsElectronMac as getIsElectronRuntimeMac,
|
getIsElectronMac as getIsElectronRuntimeMac,
|
||||||
} from "./platform";
|
} 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 {
|
export function isCompactBreakpoint(breakpoint: string | undefined): boolean {
|
||||||
return breakpoint === "xs" || breakpoint === "sm";
|
return breakpoint === "xs" || breakpoint === "sm";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1914,7 +1914,6 @@ export const ar: TranslationResources = {
|
|||||||
parent: {
|
parent: {
|
||||||
backToParent: "العودة إلى الوكيل الأصلي",
|
backToParent: "العودة إلى الوكيل الأصلي",
|
||||||
backTo: "العودة إلى {{label}}",
|
backTo: "العودة إلى {{label}}",
|
||||||
parent: "الأصل",
|
|
||||||
parentAgent: "الوكيل الأصلي",
|
parentAgent: "الوكيل الأصلي",
|
||||||
openParent: "فتح الوكيل الأصلي",
|
openParent: "فتح الوكيل الأصلي",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1939,7 +1939,6 @@ export const en = {
|
|||||||
parent: {
|
parent: {
|
||||||
backToParent: "Back to parent",
|
backToParent: "Back to parent",
|
||||||
backTo: "Back to {{label}}",
|
backTo: "Back to {{label}}",
|
||||||
parent: "Parent",
|
|
||||||
parentAgent: "Parent agent",
|
parentAgent: "Parent agent",
|
||||||
openParent: "Open parent",
|
openParent: "Open parent",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1962,7 +1962,6 @@ export const es: TranslationResources = {
|
|||||||
parent: {
|
parent: {
|
||||||
backToParent: "Volver al padre",
|
backToParent: "Volver al padre",
|
||||||
backTo: "Volver a {{label}}",
|
backTo: "Volver a {{label}}",
|
||||||
parent: "Padre",
|
|
||||||
parentAgent: "Agente padre",
|
parentAgent: "Agente padre",
|
||||||
openParent: "Abrir padre",
|
openParent: "Abrir padre",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1960,7 +1960,6 @@ export const fr: TranslationResources = {
|
|||||||
parent: {
|
parent: {
|
||||||
backToParent: "Retour au parent",
|
backToParent: "Retour au parent",
|
||||||
backTo: "Retour à {{label}}",
|
backTo: "Retour à {{label}}",
|
||||||
parent: "Parent",
|
|
||||||
parentAgent: "Agent parent",
|
parentAgent: "Agent parent",
|
||||||
openParent: "Ouvrir le parent",
|
openParent: "Ouvrir le parent",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1933,7 +1933,6 @@ export const ja: TranslationResources = {
|
|||||||
parent: {
|
parent: {
|
||||||
backToParent: "親に戻る",
|
backToParent: "親に戻る",
|
||||||
backTo: "{{label}} に戻る",
|
backTo: "{{label}} に戻る",
|
||||||
parent: "親",
|
|
||||||
parentAgent: "親エージェント",
|
parentAgent: "親エージェント",
|
||||||
openParent: "親を開く",
|
openParent: "親を開く",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1924,7 +1924,6 @@ export const ko: TranslationResources = {
|
|||||||
parent: {
|
parent: {
|
||||||
backToParent: "상위 에이전트로 돌아가기",
|
backToParent: "상위 에이전트로 돌아가기",
|
||||||
backTo: "{{label}}(으)로 돌아가기",
|
backTo: "{{label}}(으)로 돌아가기",
|
||||||
parent: "상위",
|
|
||||||
parentAgent: "상위 에이전트",
|
parentAgent: "상위 에이전트",
|
||||||
openParent: "상위 에이전트 열기",
|
openParent: "상위 에이전트 열기",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1947,7 +1947,6 @@ export const ptBR: TranslationResources = {
|
|||||||
parent: {
|
parent: {
|
||||||
backToParent: "Voltar ao pai",
|
backToParent: "Voltar ao pai",
|
||||||
backTo: "Voltar para {{label}}",
|
backTo: "Voltar para {{label}}",
|
||||||
parent: "Pai",
|
|
||||||
parentAgent: "Agente pai",
|
parentAgent: "Agente pai",
|
||||||
openParent: "Abrir pai",
|
openParent: "Abrir pai",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1946,7 +1946,6 @@ export const ru: TranslationResources = {
|
|||||||
parent: {
|
parent: {
|
||||||
backToParent: "Назад к родителю",
|
backToParent: "Назад к родителю",
|
||||||
backTo: "Назад к {{label}}",
|
backTo: "Назад к {{label}}",
|
||||||
parent: "Родитель",
|
|
||||||
parentAgent: "Родительский агент",
|
parentAgent: "Родительский агент",
|
||||||
openParent: "Открыть родителя",
|
openParent: "Открыть родителя",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1892,7 +1892,6 @@ export const zhCN: TranslationResources = {
|
|||||||
parent: {
|
parent: {
|
||||||
backToParent: "返回父代理",
|
backToParent: "返回父代理",
|
||||||
backTo: "返回 {{label}}",
|
backTo: "返回 {{label}}",
|
||||||
parent: "父代理",
|
|
||||||
parentAgent: "父代理",
|
parentAgent: "父代理",
|
||||||
openParent: "打开父代理",
|
openParent: "打开父代理",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import { useAgentDescriptor } from "@/agent-presentation";
|
|||||||
import { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host";
|
import { useToastHost, type ToastApi, type ToastState } from "@/components/toast-host";
|
||||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||||
import { useWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
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 { isNative, isWeb } from "@/constants/platform";
|
||||||
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||||
import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft";
|
import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft";
|
||||||
@@ -1512,7 +1512,7 @@ function ActiveAgentComposer({
|
|||||||
}) {
|
}) {
|
||||||
const isCompactFormFactor = useIsCompactFormFactor();
|
const isCompactFormFactor = useIsCompactFormFactor();
|
||||||
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
|
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
|
||||||
COMPACT_FORM_FACTOR_WIDTH,
|
COMPACT_CONTENT_WIDTH,
|
||||||
{ initialIsBelow: isCompactFormFactor },
|
{ initialIsBelow: isCompactFormFactor },
|
||||||
);
|
);
|
||||||
const paneContext = usePaneContext();
|
const paneContext = usePaneContext();
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
type ArchiveFinishedStatus,
|
type ArchiveFinishedStatus,
|
||||||
useArchiveSubagent,
|
useArchiveSubagent,
|
||||||
useDetachSubagent,
|
useDetachSubagent,
|
||||||
useSubagentOpensInSheet,
|
useSubagentSheetTab,
|
||||||
type SubagentRow,
|
type SubagentRow,
|
||||||
} from "@/subagents";
|
} from "@/subagents";
|
||||||
import { ParentAgentPill } from "@/subagents/parent";
|
import { ParentAgentPill } from "@/subagents/parent";
|
||||||
@@ -100,7 +100,7 @@ export const AgentTracks = memo(function AgentTracks({
|
|||||||
},
|
},
|
||||||
[openSubagentTarget],
|
[openSubagentTarget],
|
||||||
);
|
);
|
||||||
const opensInSheet = useSubagentOpensInSheet(workspaceKey);
|
const sheetTabFor = useSubagentSheetTab(workspaceKey, tabId);
|
||||||
const handleOpenChanges = useCallback(() => {
|
const handleOpenChanges = useCallback(() => {
|
||||||
if (!workspaceKey) {
|
if (!workspaceKey) {
|
||||||
return;
|
return;
|
||||||
@@ -134,7 +134,7 @@ export const AgentTracks = memo(function AgentTracks({
|
|||||||
rows={subagentRows}
|
rows={subagentRows}
|
||||||
onOpenSubagent={handleOpenSubagent}
|
onOpenSubagent={handleOpenSubagent}
|
||||||
onOpenProviderSubagent={handleOpenProviderSubagent}
|
onOpenProviderSubagent={handleOpenProviderSubagent}
|
||||||
opensInSheet={opensInSheet}
|
sheetTabFor={sheetTabFor}
|
||||||
onArchiveSubagent={archiveSubagent}
|
onArchiveSubagent={archiveSubagent}
|
||||||
onArchiveFinished={onArchiveFinished}
|
onArchiveFinished={onArchiveFinished}
|
||||||
archiveFinishedStatus={archiveFinishedStatus}
|
archiveFinishedStatus={archiveFinishedStatus}
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ import { usePaneContext } from "@/panels/pane-context";
|
|||||||
import { PaneSubtitle } from "@/panels/pane-subtitle";
|
import { PaneSubtitle } from "@/panels/pane-subtitle";
|
||||||
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
|
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import type { SheetTab } from "@/stores/workspace-layout-store";
|
||||||
import { ParentAgentCallout } from "@/subagents/parent";
|
import { ParentAgentCallout } from "@/subagents/parent";
|
||||||
import { type SubagentRow, useSubagentsForParent } from "@/subagents/select";
|
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 { SubagentsTrack } from "@/subagents/track";
|
||||||
import {
|
import {
|
||||||
providerSubagentKey,
|
providerSubagentKey,
|
||||||
@@ -51,12 +52,12 @@ function ProviderSubagentChildTrack({
|
|||||||
serverId,
|
serverId,
|
||||||
rows,
|
rows,
|
||||||
onOpenProviderSubagent,
|
onOpenProviderSubagent,
|
||||||
opensInSheet,
|
sheetTabFor,
|
||||||
}: {
|
}: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
rows: ReturnType<typeof useSubagentsForParent>;
|
rows: ReturnType<typeof useSubagentsForParent>;
|
||||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
||||||
opensInSheet: (row: SubagentRow) => boolean;
|
sheetTabFor: (row: SubagentRow) => SheetTab | null;
|
||||||
}) {
|
}) {
|
||||||
if (rows.length === 0) return null;
|
if (rows.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
@@ -66,7 +67,7 @@ function ProviderSubagentChildTrack({
|
|||||||
rows={rows}
|
rows={rows}
|
||||||
onOpenSubagent={NOOP_SUBAGENT}
|
onOpenSubagent={NOOP_SUBAGENT}
|
||||||
onOpenProviderSubagent={onOpenProviderSubagent}
|
onOpenProviderSubagent={onOpenProviderSubagent}
|
||||||
opensInSheet={opensInSheet}
|
sheetTabFor={sheetTabFor}
|
||||||
onArchiveSubagent={NOOP_SUBAGENT}
|
onArchiveSubagent={NOOP_SUBAGENT}
|
||||||
/>
|
/>
|
||||||
</ComposerTrackBar>
|
</ComposerTrackBar>
|
||||||
@@ -116,7 +117,7 @@ function useProviderSubagentDescriptor(
|
|||||||
|
|
||||||
function ProviderSubagentPanel() {
|
function ProviderSubagentPanel() {
|
||||||
const { t } = useTranslation();
|
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");
|
invariant(target.kind === "provider_subagent", "ProviderSubagentPanel requires provider target");
|
||||||
const key = providerSubagentKey(serverId, target.parentAgentId, target.subagentId);
|
const key = providerSubagentKey(serverId, target.parentAgentId, target.subagentId);
|
||||||
const streamId = `provider:${encodeURIComponent(target.parentAgentId)}:${encodeURIComponent(target.subagentId)}`;
|
const streamId = `provider:${encodeURIComponent(target.parentAgentId)}:${encodeURIComponent(target.subagentId)}`;
|
||||||
@@ -144,8 +145,9 @@ function ProviderSubagentPanel() {
|
|||||||
providerParentSubagentId: target.subagentId,
|
providerParentSubagentId: target.subagentId,
|
||||||
});
|
});
|
||||||
const childTrackClearance = resolveChildTrackClearance(childRows.length, isCompact);
|
const childTrackClearance = resolveChildTrackClearance(childRows.length, isCompact);
|
||||||
const opensInSheet = useSubagentOpensInSheet(
|
const sheetTabFor = useSubagentSheetTab(
|
||||||
buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
||||||
|
tabId,
|
||||||
);
|
);
|
||||||
const openProviderChild = useCallback(
|
const openProviderChild = useCallback(
|
||||||
(parentAgentId: string, subagentId: string) => {
|
(parentAgentId: string, subagentId: string) => {
|
||||||
@@ -274,7 +276,7 @@ function ProviderSubagentPanel() {
|
|||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
rows={childRows}
|
rows={childRows}
|
||||||
onOpenProviderSubagent={openProviderChild}
|
onOpenProviderSubagent={openProviderChild}
|
||||||
opensInSheet={opensInSheet}
|
sheetTabFor={sheetTabFor}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<ParentAgentCallout serverId={serverId} parentAgentId={target.parentAgentId} />
|
<ParentAgentCallout serverId={serverId} parentAgentId={target.parentAgentId} />
|
||||||
|
|||||||
@@ -34,11 +34,24 @@ import {
|
|||||||
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
|
import type { ContextBridge } from "@/components/ui/isolated-bottom-sheet-modal";
|
||||||
|
|
||||||
import { render as renderEnvironment } from "@testing-library/react";
|
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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useAssistantFileLinkActions, type InlinePathTarget } from "@/assistant-file-links";
|
import { useAssistantFileLinkActions, type InlinePathTarget } from "@/assistant-file-links";
|
||||||
|
|
||||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
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 root: Root;
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -155,14 +168,21 @@ describe("workspace sheet environment", () => {
|
|||||||
}, [name]);
|
}, [name]);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
function Capture() {
|
function CaptureBridge() {
|
||||||
bridge = useWorkspaceSheetEnvironment();
|
bridge = useSheetPaneBridge();
|
||||||
return (
|
return (
|
||||||
<ComposerEditingScope tabId="file" targetKey="file:a">
|
<ComposerEditingScope tabId="file" targetKey="file:a">
|
||||||
<Probe name="pane" />
|
<Probe name="pane" />
|
||||||
</ComposerEditingScope>
|
</ComposerEditingScope>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
function Capture() {
|
||||||
|
return (
|
||||||
|
<SheetPaneHostProvider value={SHEET_PANE_HOST}>
|
||||||
|
<CaptureBridge />
|
||||||
|
</SheetPaneHostProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
function Source({ workspaceRoot, active }: { workspaceRoot: string; active: boolean }) {
|
function Source({ workspaceRoot, active }: { workspaceRoot: string; active: boolean }) {
|
||||||
const onOpenFile = useCallback(
|
const onOpenFile = useCallback(
|
||||||
(target: InlinePathTarget) => {
|
(target: InlinePathTarget) => {
|
||||||
|
|||||||
@@ -122,7 +122,11 @@ import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-p
|
|||||||
import { HostVoicePage } from "@/screens/settings/voice/voice-page";
|
import { HostVoicePage } from "@/screens/settings/voice/voice-page";
|
||||||
import ProjectsScreen from "@/screens/projects-screen";
|
import ProjectsScreen from "@/screens/projects-screen";
|
||||||
import ProjectSettingsScreen from "@/screens/project-settings-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 { densityIconBox, densityIconSize } from "@/styles/density";
|
||||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||||
import {
|
import {
|
||||||
@@ -1544,12 +1548,14 @@ const desktopStyles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
contentPane: {
|
contentPane: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
minWidth: SETTINGS_DESKTOP_DETAIL_MIN_WIDTH,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const sidebarStyles = StyleSheet.create((theme) => ({
|
const sidebarStyles = StyleSheet.create((theme) => ({
|
||||||
desktopContainer: {
|
desktopContainer: {
|
||||||
width: SETTINGS_DESKTOP_SIDEBAR_WIDTH,
|
width: SETTINGS_DESKTOP_SIDEBAR_WIDTH,
|
||||||
|
flexShrink: 1,
|
||||||
borderRightWidth: 1,
|
borderRightWidth: 1,
|
||||||
borderRightColor: theme.colors.border,
|
borderRightColor: theme.colors.border,
|
||||||
backgroundColor: theme.colors.surfaceSidebar,
|
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 { useIsFocused } from "expo-router/react-navigation";
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
type ComponentType,
|
FULL_SHEET_SNAP_POINTS,
|
||||||
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 {
|
|
||||||
SheetBackground,
|
SheetBackground,
|
||||||
SheetHandle,
|
SheetHandle,
|
||||||
SheetHeaderIconButton,
|
|
||||||
SheetHeaderView,
|
|
||||||
type SheetHeader,
|
|
||||||
} from "@/components/adaptive-modal-sheet";
|
} from "@/components/adaptive-modal-sheet";
|
||||||
import {
|
import {
|
||||||
IsolatedBottomSheetModal,
|
IsolatedBottomSheetModal,
|
||||||
type IsolatedBottomSheetModalRef,
|
type IsolatedBottomSheetModalRef,
|
||||||
systemBackPress,
|
systemBackPress,
|
||||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
} 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 {
|
import {
|
||||||
WorkspacePaneContent,
|
SheetPane,
|
||||||
type WorkspacePaneContentModel,
|
useSheetPaneBridge,
|
||||||
} from "@/screens/workspace/workspace-pane-content";
|
useSheetPaneHost,
|
||||||
|
useSheetPaneHostIfAny,
|
||||||
|
} from "@/screens/workspace/sheet-pane";
|
||||||
import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store";
|
import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store";
|
||||||
import { useWorkspaceSheetEnvironment } from "./workspace-sheet-environment";
|
|
||||||
|
|
||||||
const SNAP_POINTS = ["92%"];
|
|
||||||
const NO_SHEETS: readonly SheetTab[] = [];
|
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 {
|
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. */
|
/** The workspace's ordinary tab close, including its unsaved-changes guard. */
|
||||||
onCloseTab: (tabId: string) => Promise<void>;
|
onCloseTab: (tabId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SheetTabSheetProps extends Omit<SheetTabsProps, "workspaceKey" | "onCloseTab"> {
|
interface SheetTabSheetProps {
|
||||||
sheet: SheetTab;
|
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;
|
isTop: boolean;
|
||||||
/** Runs the guarded close and resolves to whether the tab is still open. */
|
/** Runs the guarded close and resolves to whether the tab is still open. */
|
||||||
onClose: (tabId: string) => Promise<boolean>;
|
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
|
* 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
|
* crosses into the app overlay host so compact sheets cover both sidebars, and a sheet opened
|
||||||
* sheet opened from any other sheet (a subagent chosen from the subagents list, say) lands on top of
|
* from another one lands on top of it and uncovers it on close.
|
||||||
* it and uncovers it on close. The shared sheet parts (`SheetBackground`, `SheetHandle`,
|
|
||||||
* `SheetHeaderView`) keep them looking and dragging like every other sheet.
|
|
||||||
*/
|
*/
|
||||||
export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps) {
|
export function SheetTabs({ onCloseTab }: SheetTabsProps) {
|
||||||
const sheets = useWorkspaceLayoutStore((state) =>
|
const host = useSheetPaneHostIfAny();
|
||||||
workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS,
|
return host ? (
|
||||||
);
|
<WorkspaceSheetTabs workspaceKey={host.workspaceKey} onCloseTab={onCloseTab} />
|
||||||
const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab);
|
) : null;
|
||||||
const handleOpenAsTab = useCallback(
|
}
|
||||||
(tabId: string) => {
|
|
||||||
if (workspaceKey) openSheetAsTab(workspaceKey, tabId);
|
function WorkspaceSheetTabs({
|
||||||
},
|
workspaceKey,
|
||||||
[openSheetAsTab, workspaceKey],
|
onCloseTab,
|
||||||
|
}: SheetTabsProps & { workspaceKey: string }) {
|
||||||
|
const sheets = useWorkspaceLayoutStore(
|
||||||
|
(state) => state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS,
|
||||||
);
|
);
|
||||||
const handleClose = useCallback(
|
const handleClose = useCallback(
|
||||||
async (tabId: string) => {
|
async (tabId: string) => {
|
||||||
await onCloseTab(tabId);
|
await onCloseTab(tabId);
|
||||||
// A declined unsaved-changes prompt leaves the tab in its sheet.
|
// A declined unsaved-changes prompt leaves the tab in its sheet.
|
||||||
const open = workspaceKey
|
const open = useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey];
|
||||||
? useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey]
|
|
||||||
: undefined;
|
|
||||||
return open?.some((sheet) => sheet.tab.tabId === tabId) ?? false;
|
return open?.some((sheet) => sheet.tab.tabId === tabId) ?? false;
|
||||||
},
|
},
|
||||||
[onCloseTab, workspaceKey],
|
[onCloseTab, workspaceKey],
|
||||||
@@ -136,42 +67,21 @@ export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps)
|
|||||||
<SheetTabSheet
|
<SheetTabSheet
|
||||||
// Keyed by tab id and kind, so the registration and its descriptor hook stay fixed.
|
// Keyed by tab id and kind, so the registration and its descriptor hook stay fixed.
|
||||||
key={`${sheet.tab.tabId}:${sheet.tab.target.kind}`}
|
key={`${sheet.tab.tabId}:${sheet.tab.target.kind}`}
|
||||||
{...rest}
|
|
||||||
sheet={sheet}
|
sheet={sheet}
|
||||||
isTop={index === sheets.length - 1}
|
isTop={index === sheets.length - 1}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
onOpenAsTab={handleOpenAsTab}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetTabSheet({
|
function SheetTabSheet({ sheet, isTop, onClose }: SheetTabSheetProps) {
|
||||||
sheet,
|
|
||||||
isTop,
|
|
||||||
serverId,
|
|
||||||
workspaceId,
|
|
||||||
isWorkspaceFocused,
|
|
||||||
buildContent,
|
|
||||||
onClose,
|
|
||||||
onOpenAsTab,
|
|
||||||
}: SheetTabSheetProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { tab } = sheet;
|
const { tab } = sheet;
|
||||||
const sheetRef = useRef<IsolatedBottomSheetModalRef>(null);
|
const sheetRef = useRef<IsolatedBottomSheetModalRef>(null);
|
||||||
const isScreenFocused = useIsFocused();
|
const isScreenFocused = useIsFocused();
|
||||||
const contextBridge = useWorkspaceSheetEnvironment();
|
const { isWorkspaceFocused } = useSheetPaneHost();
|
||||||
ensurePanelsRegistered();
|
const contextBridge = useSheetPaneBridge();
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
// The sheet lives as long as its store entry: it presents on mount, and unmounting takes it off
|
// 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
|
// the stack, which uncovers the sheet below. Gorhom slides an unmounting sheet down and still
|
||||||
@@ -209,105 +119,23 @@ function SheetTabSheet({
|
|||||||
});
|
});
|
||||||
}, [close, ownsBackPress]);
|
}, [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 (
|
return (
|
||||||
<IsolatedBottomSheetModal
|
<IsolatedBottomSheetModal
|
||||||
ref={sheetRef}
|
ref={sheetRef}
|
||||||
contextBridge={contextBridge}
|
contextBridge={contextBridge}
|
||||||
snapPoints={SNAP_POINTS}
|
snapPoints={FULL_SHEET_SNAP_POINTS}
|
||||||
enableDynamicSizing={false}
|
enableDynamicSizing={false}
|
||||||
enablePanDownToClose
|
enablePanDownToClose
|
||||||
|
// The pane scrolls on its own; the sheet drags from its handle and header.
|
||||||
enableContentPanningGesture={false}
|
enableContentPanningGesture={false}
|
||||||
enableDismissOnClose={false}
|
enableDismissOnClose={false}
|
||||||
backgroundComponent={SheetBackground}
|
backgroundComponent={SheetBackground}
|
||||||
handleComponent={SheetTabHandle}
|
handleComponent={SheetHandle}
|
||||||
backdropOpacity={0.45}
|
backdropOpacity={0.45}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
accessible={false}
|
accessible={false}
|
||||||
>
|
>
|
||||||
{/* A covered sheet stays as live as the pane under it; only focus follows the top sheet. */}
|
<SheetPane sheet={sheet} onClose={close} isTop={isTop} />
|
||||||
<SheetTabBody
|
|
||||||
tab={tab}
|
|
||||||
label={descriptor.label}
|
|
||||||
isPaneFocused={isTop && isWorkspaceFocused}
|
|
||||||
isWorkspaceFocused={isWorkspaceFocused}
|
|
||||||
buildContent={buildContent}
|
|
||||||
/>
|
|
||||||
</IsolatedBottomSheetModal>
|
</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"] });
|
}).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", () => {
|
test("single-pane tablet includes its visible Explorer agent without including other splits", () => {
|
||||||
const layout: WorkspaceLayout = {
|
const layout: WorkspaceLayout = {
|
||||||
focusedPaneId: "main",
|
focusedPaneId: "main",
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { deriveWorkspacePaneState } from "./workspace-pane-state";
|
|||||||
export function selectVisibleAgentIds(input: {
|
export function selectVisibleAgentIds(input: {
|
||||||
layout: WorkspaceLayout | null;
|
layout: WorkspaceLayout | null;
|
||||||
tabs: WorkspaceTab[];
|
tabs: WorkspaceTab[];
|
||||||
/** The workspace sheet's tab, shown over the panes. */
|
|
||||||
sheetTab?: WorkspaceTab | null;
|
|
||||||
routeFocused: boolean;
|
routeFocused: boolean;
|
||||||
focusedPaneOnly: boolean;
|
focusedPaneOnly: boolean;
|
||||||
explorerPaneId?: string | null;
|
explorerPaneId?: string | null;
|
||||||
@@ -29,7 +27,6 @@ export function selectVisibleAgentIds(input: {
|
|||||||
.target;
|
.target;
|
||||||
return target ? [target] : [];
|
return target ? [target] : [];
|
||||||
});
|
});
|
||||||
if (input.sheetTab) targets.push(input.sheetTab.target);
|
|
||||||
return [
|
return [
|
||||||
...new Set(targets.flatMap((target) => (target.kind === "agent" ? [target.agentId] : []))),
|
...new Set(targets.flatMap((target) => (target.kind === "agent" ? [target.agentId] : []))),
|
||||||
].sort();
|
].sort();
|
||||||
@@ -38,7 +35,6 @@ export function selectVisibleAgentIds(input: {
|
|||||||
export function useVisibleAgentIds(input: {
|
export function useVisibleAgentIds(input: {
|
||||||
layout: WorkspaceLayout | null;
|
layout: WorkspaceLayout | null;
|
||||||
tabs: WorkspaceTab[];
|
tabs: WorkspaceTab[];
|
||||||
sheetTab: WorkspaceTab | null;
|
|
||||||
routeFocused: boolean;
|
routeFocused: boolean;
|
||||||
focusedPaneOnly: boolean;
|
focusedPaneOnly: boolean;
|
||||||
explorerPaneId?: string | null;
|
explorerPaneId?: string | null;
|
||||||
|
|||||||
@@ -11,18 +11,7 @@ import {
|
|||||||
} from "@/screens/workspace/workspace-pane-content";
|
} from "@/screens/workspace/workspace-pane-content";
|
||||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
import { usePaneContext, usePaneFocus, type PaneContextValue } from "@/panels/pane-context";
|
import { usePaneContext, usePaneFocus, type PaneContextValue } from "@/panels/pane-context";
|
||||||
|
import { definePanel, registerPanel } from "@/panels/panel-registry";
|
||||||
vi.mock("@/panels/register-panels", () => ({
|
|
||||||
ensurePanelsRegistered: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/panels/panel-registry", () => ({
|
|
||||||
getPanelRegistration: () => ({
|
|
||||||
kind: "agent",
|
|
||||||
component: ProbePanel,
|
|
||||||
useDescriptor: vi.fn(),
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
interface ProbeSnapshot {
|
interface ProbeSnapshot {
|
||||||
paneContextValue: PaneContextValue;
|
paneContextValue: PaneContextValue;
|
||||||
@@ -48,6 +37,18 @@ function ProbePanel() {
|
|||||||
return null;
|
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 = {
|
const agentTab: WorkspaceTabDescriptor = {
|
||||||
key: "agent_agent-a",
|
key: "agent_agent-a",
|
||||||
tabId: "agent_agent-a",
|
tabId: "agent_agent-a",
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from "@/panels/pane-context";
|
} from "@/panels/pane-context";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { getPanelRegistration } 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 type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
import { RenderProfile } from "@/utils/render-profiler";
|
import { RenderProfile } from "@/utils/render-profiler";
|
||||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||||
@@ -56,7 +55,6 @@ export function buildWorkspacePaneContentModel({
|
|||||||
onOpenWorkspaceFile,
|
onOpenWorkspaceFile,
|
||||||
onOpenImportSheet,
|
onOpenImportSheet,
|
||||||
}: BuildWorkspacePaneContentModelInput): WorkspacePaneContentModel {
|
}: BuildWorkspacePaneContentModelInput): WorkspacePaneContentModel {
|
||||||
ensurePanelsRegistered();
|
|
||||||
const registration = getPanelRegistration(tab.kind);
|
const registration = getPanelRegistration(tab.kind);
|
||||||
invariant(registration, `No panel registration for kind: ${tab.kind}`);
|
invariant(registration, `No panel registration for kind: ${tab.kind}`);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -180,6 +180,8 @@ import {
|
|||||||
type WorkspacePaneContentModel,
|
type WorkspacePaneContentModel,
|
||||||
} from "@/screens/workspace/workspace-pane-content";
|
} from "@/screens/workspace/workspace-pane-content";
|
||||||
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
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 { SheetTabs } from "@/screens/workspace/sheet-tabs";
|
||||||
import type { PaneHost } from "@/panels/panel-manifest";
|
import type { PaneHost } from "@/panels/panel-manifest";
|
||||||
import { WorkspaceFocusProvider } from "@/workspace/focus";
|
import { WorkspaceFocusProvider } from "@/workspace/focus";
|
||||||
@@ -421,6 +423,10 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
isRouteFocused,
|
isRouteFocused,
|
||||||
}: WorkspaceScreenProps) {
|
}: 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();
|
const navigationFocused = useIsFocused();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
traceInstant("paseo.workspace.mount", { serverId, workspaceId });
|
traceInstant("paseo.workspace.mount", { serverId, workspaceId });
|
||||||
@@ -764,13 +770,17 @@ function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) {
|
|||||||
function WorkspaceContentProviders({
|
function WorkspaceContentProviders({
|
||||||
children,
|
children,
|
||||||
workspaceKey,
|
workspaceKey,
|
||||||
|
sheetPaneHost,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
workspaceKey: string | null;
|
workspaceKey: string | null;
|
||||||
|
sheetPaneHost: SheetPaneHost | null;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<WorkspaceFocusProvider workspaceKey={workspaceKey}>
|
<WorkspaceFocusProvider workspaceKey={workspaceKey}>
|
||||||
<DiffDocumentWorkspaceCacheProvider>{children}</DiffDocumentWorkspaceCacheProvider>
|
<DiffDocumentWorkspaceCacheProvider>
|
||||||
|
<SheetPaneHostProvider value={sheetPaneHost}>{children}</SheetPaneHostProvider>
|
||||||
|
</DiffDocumentWorkspaceCacheProvider>
|
||||||
</WorkspaceFocusProvider>
|
</WorkspaceFocusProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1342,10 +1352,6 @@ function WorkspaceScreenContent({
|
|||||||
const sheets = useWorkspaceLayoutStore((state) =>
|
const sheets = useWorkspaceLayoutStore((state) =>
|
||||||
persistenceKey ? (state.sheetsByWorkspace[persistenceKey] ?? NO_SHEETS) : NO_SHEETS,
|
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 unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
|
||||||
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
|
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
|
||||||
const setWorkspaceTabState = useWorkspaceLayoutStore((state) => state.setTabState);
|
const setWorkspaceTabState = useWorkspaceLayoutStore((state) => state.setTabState);
|
||||||
@@ -1417,7 +1423,6 @@ function WorkspaceScreenContent({
|
|||||||
const visibleAgentIds = useVisibleAgentIds({
|
const visibleAgentIds = useVisibleAgentIds({
|
||||||
layout: workspaceLayout,
|
layout: workspaceLayout,
|
||||||
tabs: uiTabs,
|
tabs: uiTabs,
|
||||||
sheetTab,
|
|
||||||
routeFocused: isRouteFocused,
|
routeFocused: isRouteFocused,
|
||||||
focusedPaneOnly: syncFocusedPaneOnly,
|
focusedPaneOnly: syncFocusedPaneOnly,
|
||||||
explorerPaneId: explorerSidebarPaneId,
|
explorerPaneId: explorerSidebarPaneId,
|
||||||
@@ -2916,6 +2921,8 @@ function WorkspaceScreenContent({
|
|||||||
paneId?: string | null;
|
paneId?: string | null;
|
||||||
focusPaneBeforeOpen?: boolean;
|
focusPaneBeforeOpen?: boolean;
|
||||||
host?: PaneHost;
|
host?: PaneHost;
|
||||||
|
/** Overrides the guarded tab close, for a host that closes the tab its own way. */
|
||||||
|
onCloseCurrentTab?: () => void;
|
||||||
}) =>
|
}) =>
|
||||||
buildWorkspacePaneContentModel({
|
buildWorkspacePaneContentModel({
|
||||||
tab: input.tab,
|
tab: input.tab,
|
||||||
@@ -2965,9 +2972,11 @@ function WorkspaceScreenContent({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
onCloseCurrentTab: () => {
|
onCloseCurrentTab:
|
||||||
void handleCloseTabById(input.tab.tabId);
|
input.onCloseCurrentTab ??
|
||||||
},
|
(() => {
|
||||||
|
void handleCloseTabById(input.tab.tabId);
|
||||||
|
}),
|
||||||
onRetargetCurrentTab: (target) => {
|
onRetargetCurrentTab: (target) => {
|
||||||
if (!persistenceKey) {
|
if (!persistenceKey) {
|
||||||
return;
|
return;
|
||||||
@@ -3042,16 +3051,36 @@ function WorkspaceScreenContent({
|
|||||||
[buildPaneContentModel],
|
[buildPaneContentModel],
|
||||||
);
|
);
|
||||||
const buildSheetPaneContentModel = useCallback(
|
const buildSheetPaneContentModel = useCallback(
|
||||||
function buildSheetPaneContentModel(tab: WorkspaceTabDescriptor) {
|
function buildSheetPaneContentModel(tab: WorkspaceTabDescriptor, closeCurrentTab: () => void) {
|
||||||
return buildPaneContentModel({
|
return buildPaneContentModel({
|
||||||
tab,
|
tab,
|
||||||
paneId: null,
|
paneId: null,
|
||||||
focusPaneBeforeOpen: false,
|
focusPaneBeforeOpen: false,
|
||||||
host: "sheet",
|
host: "sheet",
|
||||||
|
onCloseCurrentTab: closeCurrentTab,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[buildPaneContentModel],
|
[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) {
|
const handleFocusPane = useStableEvent(function handleFocusPane(paneId: string) {
|
||||||
if (!persistenceKey || paneFocusSuppressedRef.current) {
|
if (!persistenceKey || paneFocusSuppressedRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -3540,14 +3569,7 @@ function WorkspaceScreenContent({
|
|||||||
</StreamTopOverlayProvider>
|
</StreamTopOverlayProvider>
|
||||||
)}
|
)}
|
||||||
</FloatingHeaderLayout>
|
</FloatingHeaderLayout>
|
||||||
<SheetTabs
|
<SheetTabs onCloseTab={handleCloseTabById} />
|
||||||
workspaceKey={persistenceKey}
|
|
||||||
serverId={normalizedServerId}
|
|
||||||
workspaceId={normalizedWorkspaceId}
|
|
||||||
isWorkspaceFocused={isRouteFocused}
|
|
||||||
buildContent={buildSheetPaneContentModel}
|
|
||||||
onCloseTab={handleCloseTabById}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.centerColumn}>
|
<View style={styles.centerColumn}>
|
||||||
@@ -3767,7 +3789,11 @@ function WorkspaceScreenContent({
|
|||||||
return gatedWorkspaceScreen;
|
return gatedWorkspaceScreen;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<WorkspaceContentProviders key={persistenceKey} workspaceKey={persistenceKey}>
|
<WorkspaceContentProviders
|
||||||
|
key={persistenceKey}
|
||||||
|
workspaceKey={persistenceKey}
|
||||||
|
sheetPaneHost={sheetPaneHost}
|
||||||
|
>
|
||||||
<AssistantFileLinkResolverProvider
|
<AssistantFileLinkResolverProvider
|
||||||
client={client}
|
client={client}
|
||||||
serverId={normalizedServerId}
|
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 { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
|
||||||
import { getPanelRegistration, type PanelIconProps } from "@/panels/panel-registry";
|
import { getPanelRegistration, type PanelIconProps } from "@/panels/panel-registry";
|
||||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
@@ -43,7 +42,6 @@ export function WorkspaceTabPresentationResolver({
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
children,
|
children,
|
||||||
}: WorkspaceTabPresentationResolverProps): ReactElement {
|
}: WorkspaceTabPresentationResolverProps): ReactElement {
|
||||||
ensurePanelsRegistered();
|
|
||||||
const registration = getPanelRegistration(tab.kind);
|
const registration = getPanelRegistration(tab.kind);
|
||||||
invariant(registration, `No panel registration for kind: ${tab.kind}`);
|
invariant(registration, `No panel registration for kind: ${tab.kind}`);
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
normalizeLayout,
|
normalizeLayout,
|
||||||
removePaneFromTree,
|
removePaneFromTree,
|
||||||
removeTabFromTree,
|
removeTabFromTree,
|
||||||
|
selectSheetTab,
|
||||||
stripEphemeralTabsFromLayout,
|
stripEphemeralTabsFromLayout,
|
||||||
type SplitNode,
|
type SplitNode,
|
||||||
type SplitPane,
|
type SplitPane,
|
||||||
@@ -4496,6 +4497,12 @@ describe("compact workspace sheet", () => {
|
|||||||
return store.getState().sheetsByWorkspace[workspaceKey]?.at(-1);
|
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>) {
|
function sheetTargets(store: ReturnType<typeof createCompactStore>) {
|
||||||
return (store.getState().sheetsByWorkspace[workspaceKey] ?? []).map(
|
return (store.getState().sheetsByWorkspace[workspaceKey] ?? []).map(
|
||||||
(sheet) => sheet.tab.target,
|
(sheet) => sheet.tab.target,
|
||||||
@@ -4647,7 +4654,7 @@ describe("compact workspace sheet", () => {
|
|||||||
.getState()
|
.getState()
|
||||||
.openTab({ workspaceKey, target: { kind: "file", path: "src/other.ts" }, intent: "reveal" });
|
.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(sheetTargets(store)).toEqual([]);
|
||||||
expect(tabKinds(store)).toEqual(["terminal", "file"]);
|
expect(tabKinds(store)).toEqual(["terminal", "file"]);
|
||||||
@@ -4683,7 +4690,7 @@ describe("compact workspace sheet", () => {
|
|||||||
if (!sheetTabId) throw new Error("Expected a sheet tab");
|
if (!sheetTabId) throw new Error("Expected a sheet tab");
|
||||||
store.getState().setTabState(workspaceKey, sheetTabId, { scrollTop: 120 });
|
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(tabId).toBe(sheetTabId);
|
||||||
expect(topSheet(store)).toBeUndefined();
|
expect(topSheet(store)).toBeUndefined();
|
||||||
@@ -4787,6 +4794,40 @@ describe("compact workspace sheet", () => {
|
|||||||
expect(tabKinds(store)).toEqual(["terminal", "agent", "agent"]);
|
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", () => {
|
it("gives a sheet target opened from inside the sheet the sheet's own parent", () => {
|
||||||
seedAgents([
|
seedAgents([
|
||||||
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
|
||||||
@@ -4811,7 +4852,7 @@ describe("compact workspace sheet", () => {
|
|||||||
});
|
});
|
||||||
expect(topSheet(store)?.parentTabId).toBe(rootTabId);
|
expect(topSheet(store)?.parentTabId).toBe(rootTabId);
|
||||||
|
|
||||||
store.getState().openSheetAsTab(workspaceKey, fileSheetTabId ?? "");
|
store.getState().openTabInFront(workspaceKey, requireTopSheet(store));
|
||||||
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
const layout = store.getState().layoutByWorkspace[workspaceKey];
|
||||||
expect(layout.parentTabIdByTabId?.[fileSheetTabId ?? ""]).toBe(rootTabId);
|
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.
|
* `compactSheet` opens in a sheet; the returned tab id is the sheet tab's id.
|
||||||
*/
|
*/
|
||||||
openTab: (input: OpenWorkspaceTabInput) => string | null;
|
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;
|
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. */
|
/** Placement resolves lazily so an already acknowledged workspace never creates a side pane. */
|
||||||
autoOpenPullRequestTab: (
|
autoOpenPullRequestTab: (
|
||||||
workspaceKey: string,
|
workspaceKey: string,
|
||||||
@@ -521,6 +520,29 @@ export function selectExplorerSidebarPaneId(
|
|||||||
return resolveExplorerSidebarPaneId(layout, state.explorerSidebarPaneIdByWorkspace[workspaceKey]);
|
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. */
|
/** A workspace's panes give up keyboard focus while focus is parked or the sheet covers them. */
|
||||||
export function selectPaneFocusSuspended(
|
export function selectPaneFocusSuspended(
|
||||||
state: Pick<WorkspaceLayoutStore, "focusRestorationByWorkspace" | "sheetsByWorkspace">,
|
state: Pick<WorkspaceLayoutStore, "focusRestorationByWorkspace" | "sheetsByWorkspace">,
|
||||||
@@ -822,10 +844,8 @@ function placeTargetInSheet(input: {
|
|||||||
function promoteSheetToTab(
|
function promoteSheetToTab(
|
||||||
state: WorkspaceLayoutStore,
|
state: WorkspaceLayoutStore,
|
||||||
workspaceKey: string,
|
workspaceKey: string,
|
||||||
tabId: string,
|
sheet: SheetTab,
|
||||||
): { tabId: string; patch: Partial<WorkspaceLayoutStore> } | null {
|
): { tabId: string; patch: Partial<WorkspaceLayoutStore> } | null {
|
||||||
const sheet = findSheet(state, workspaceKey, tabId);
|
|
||||||
if (!sheet) return null;
|
|
||||||
const { tab } = sheet;
|
const { tab } = sheet;
|
||||||
const placement = getOpenTabPlacement(state, workspaceKey, tab.target, FOCUSED_PANE_PLACEMENT);
|
const placement = getOpenTabPlacement(state, workspaceKey, tab.target, FOCUSED_PANE_PLACEMENT);
|
||||||
const input = {
|
const input = {
|
||||||
@@ -910,9 +930,9 @@ function dismissSheets(
|
|||||||
function openSheetTabInFront(
|
function openSheetTabInFront(
|
||||||
state: WorkspaceLayoutStore,
|
state: WorkspaceLayoutStore,
|
||||||
workspaceKey: string,
|
workspaceKey: string,
|
||||||
tabId: string,
|
sheet: SheetTab,
|
||||||
): { tabId: string; patch: Partial<WorkspaceLayoutStore> } | null {
|
): { tabId: string; patch: Partial<WorkspaceLayoutStore> } | null {
|
||||||
const promoted = promoteSheetToTab(state, workspaceKey, tabId);
|
const promoted = promoteSheetToTab(state, workspaceKey, sheet);
|
||||||
if (!promoted) return null;
|
if (!promoted) return null;
|
||||||
const next = { ...state, ...promoted.patch };
|
const next = { ...state, ...promoted.patch };
|
||||||
return {
|
return {
|
||||||
@@ -1208,15 +1228,6 @@ export function createWorkspaceLayoutStore(
|
|||||||
}));
|
}));
|
||||||
return result.tabId;
|
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) => {
|
setLayoutMode: (mode) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (state.layoutMode === mode) return state;
|
if (state.layoutMode === mode) return state;
|
||||||
@@ -1225,7 +1236,7 @@ export function createWorkspaceLayoutStore(
|
|||||||
const next: WorkspaceLayoutStore = { ...state, layoutMode: mode };
|
const next: WorkspaceLayoutStore = { ...state, layoutMode: mode };
|
||||||
for (const [workspaceKey, sheets] of Object.entries(state.sheetsByWorkspace)) {
|
for (const [workspaceKey, sheets] of Object.entries(state.sheetsByWorkspace)) {
|
||||||
for (const sheet of sheets) {
|
for (const sheet of sheets) {
|
||||||
const promoted = promoteSheetToTab(next, workspaceKey, sheet.tab.tabId);
|
const promoted = promoteSheetToTab(next, workspaceKey, sheet);
|
||||||
Object.assign(
|
Object.assign(
|
||||||
next,
|
next,
|
||||||
promoted?.patch ?? withoutSheet(next, workspaceKey, sheet.tab.tabId),
|
promoted?.patch ?? withoutSheet(next, workspaceKey, sheet.tab.tabId),
|
||||||
@@ -1235,11 +1246,10 @@ export function createWorkspaceLayoutStore(
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
openSheetAsTab: (workspaceKey, tabId) => {
|
openTabInFront: (workspaceKey, sheet) => {
|
||||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||||
const normalizedTabId = trimNonEmpty(tabId);
|
if (!normalizedWorkspaceKey) return null;
|
||||||
if (!normalizedWorkspaceKey || !normalizedTabId) return null;
|
const promoted = openSheetTabInFront(get(), normalizedWorkspaceKey, sheet);
|
||||||
const promoted = openSheetTabInFront(get(), normalizedWorkspaceKey, normalizedTabId);
|
|
||||||
if (!promoted) return null;
|
if (!promoted) return null;
|
||||||
set(promoted.patch);
|
set(promoted.patch);
|
||||||
return promoted.tabId;
|
return promoted.tabId;
|
||||||
@@ -1569,7 +1579,10 @@ export function createWorkspaceLayoutStore(
|
|||||||
return normalizedTabId;
|
return normalizedTabId;
|
||||||
}
|
}
|
||||||
// A target sheets do not host becomes the tab it would have opened as.
|
// 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));
|
set(promoted?.patch ?? withoutSheet(next, normalizedWorkspaceKey, normalizedTabId));
|
||||||
return promoted?.tabId ?? null;
|
return promoted?.tabId ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ StyleSheet.configure({
|
|||||||
breakpoints: {
|
breakpoints: {
|
||||||
xs: 0,
|
xs: 0,
|
||||||
sm: 576,
|
sm: 576,
|
||||||
medium: 600,
|
md: 600,
|
||||||
md: 720,
|
|
||||||
lg: 992,
|
lg: 992,
|
||||||
xl: 1200,
|
xl: 1200,
|
||||||
},
|
},
|
||||||
@@ -22,7 +21,6 @@ type AppThemes = typeof REGISTERED_THEMES;
|
|||||||
interface AppBreakpoints {
|
interface AppBreakpoints {
|
||||||
xs: number;
|
xs: number;
|
||||||
sm: number;
|
sm: number;
|
||||||
medium: number;
|
|
||||||
md: number;
|
md: number;
|
||||||
lg: number;
|
lg: number;
|
||||||
xl: number;
|
xl: number;
|
||||||
|
|||||||
@@ -9,4 +9,4 @@ export {
|
|||||||
type ArchiveFinishedStatus,
|
type ArchiveFinishedStatus,
|
||||||
type UseArchiveFinishedSubagentsInput,
|
type UseArchiveFinishedSubagentsInput,
|
||||||
} from "./use-archive-finished";
|
} 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 { useTranslation } from "react-i18next";
|
||||||
import { CornerUpLeft } from "lucide-react-native";
|
import { CornerUpLeft } from "lucide-react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import { StatusIcon } from "@/components/status-icon";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ComposerTrackMark } from "@/composer/tracks";
|
|
||||||
import { FOOTER_HEIGHT } from "@/constants/layout";
|
import { FOOTER_HEIGHT } from "@/constants/layout";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { useParentAgent, type ParentAgent } from "./use-parent-agent";
|
import { useParentAgent, type ParentAgent } from "./use-parent-agent";
|
||||||
@@ -22,6 +22,7 @@ function ParentIcon({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ThemedParentIcon = withUnistyles(ParentIcon);
|
const ThemedParentIcon = withUnistyles(ParentIcon);
|
||||||
|
const ICON_SIZE = 16;
|
||||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
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. */
|
/** 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 (
|
return (
|
||||||
<View style={styles.rail}>
|
<View style={styles.rail}>
|
||||||
<View style={styles.callout} testID="parent-agent-callout">
|
<View style={styles.callout} testID="parent-agent-callout">
|
||||||
<View style={styles.avatar}>
|
<StatusIcon statusBucket={parent.statusBucket} size={ICON_SIZE} backdrop="surface1">
|
||||||
<ThemedParentIcon icon={parent.icon} size={18} uniProps={foregroundColorMapping} />
|
<ThemedParentIcon icon={parent.icon} size={ICON_SIZE} uniProps={foregroundColorMapping} />
|
||||||
</View>
|
</StatusIcon>
|
||||||
<View style={styles.body}>
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
<Text style={styles.eyebrow}>{t("subagents.parent.parent")}</Text>
|
{parent.label ?? t("subagents.parent.parentAgent")}
|
||||||
<View style={styles.titleRow}>
|
</Text>
|
||||||
<Text style={styles.title} numberOfLines={1}>
|
|
||||||
{parent.label ?? t("subagents.parent.parentAgent")}
|
|
||||||
</Text>
|
|
||||||
<ComposerTrackMark bucket={parent.statusBucket} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -65,11 +60,13 @@ export function ParentAgentCallout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
// The composer's rail, so the callout sits where the composer would.
|
||||||
rail: {
|
rail: {
|
||||||
minHeight: FOOTER_HEIGHT,
|
minHeight: FOOTER_HEIGHT,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
padding: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingBottom: theme.spacing[4],
|
||||||
},
|
},
|
||||||
callout: {
|
callout: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -84,31 +81,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
padding: theme.spacing[3],
|
padding: theme.spacing[3],
|
||||||
paddingRight: { xs: theme.spacing[3], md: theme.spacing[4] },
|
paddingRight: { xs: theme.spacing[3], md: theme.spacing[4] },
|
||||||
},
|
},
|
||||||
avatar: {
|
// Shrinks before the button does, so Open parent never wraps or leaves the row.
|
||||||
width: 36,
|
title: {
|
||||||
height: 36,
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
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,
|
fontSize: theme.fontSize.base,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.medium,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useAgentDescriptor } from "@/agent-presentation";
|
import { useAgentDescriptor } from "@/agent-presentation";
|
||||||
import type { PanelDescriptor } from "@/panels/panel-registry";
|
import type { PanelDescriptor } from "@/panels/panel-registry";
|
||||||
|
import { useLeaveSheet } from "@/screens/workspace/sheet-pane";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
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. */
|
/** The parent as its own tab presents it, and the way back to it. */
|
||||||
export function useParentAgent(serverId: string, parentAgentId: string): ParentAgent {
|
export function useParentAgent(serverId: string, parentAgentId: string): ParentAgent {
|
||||||
const descriptor = useAgentDescriptor({ kind: "agent", agentId: parentAgentId }, { serverId });
|
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(() => {
|
const open = useCallback(() => {
|
||||||
|
if (leaveSheet({ kind: "agent", agentId: parentAgentId })) return;
|
||||||
navigateToAgent({ serverId, agentId: parentAgentId });
|
navigateToAgent({ serverId, agentId: parentAgentId });
|
||||||
}, [parentAgentId, serverId]);
|
}, [leaveSheet, parentAgentId, serverId]);
|
||||||
return {
|
return {
|
||||||
label: descriptor.label || null,
|
label: descriptor.label || null,
|
||||||
icon: descriptor.icon,
|
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 { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
import { useSubagentOpensInSheet } from "./opens-in-sheet";
|
import { useSubagentSheetTab } from "./sheet-tab";
|
||||||
import { selectSubagentsForParent, type SubagentRow } from "./select";
|
import { selectSubagentsForParent, type SubagentRow } from "./select";
|
||||||
|
|
||||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||||
@@ -116,28 +116,40 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("useSubagentOpensInSheet", () => {
|
describe("useSubagentSheetTab", () => {
|
||||||
it("re-renders the row's answer as the child's tab opens and closes", () => {
|
it("re-renders the row's sheet as the child's tab opens and closes", () => {
|
||||||
const row = seedParentWithChild();
|
const row = seedParentWithChild();
|
||||||
const store = useWorkspaceLayoutStore.getState();
|
const store = useWorkspaceLayoutStore.getState();
|
||||||
store.setLayoutMode("compact");
|
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.
|
// The row renders its answer, as the track does, so a stale render is a stale answer.
|
||||||
const { result } = renderHook(() => useSubagentOpensInSheet(workspaceKey)(row));
|
const { result } = renderHook(() => useSubagentSheetTab(workspaceKey, "parent-tab")(row));
|
||||||
expect(result.current).toBe(true);
|
expect(result.current?.tab.target).toEqual({ kind: "agent", agentId: "child" });
|
||||||
|
|
||||||
let tabId: string | null = null;
|
let tabId: string | null = null;
|
||||||
act(() => {
|
act(() => {
|
||||||
const sheetTabId = store.openTab({ workspaceKey, target, intent: "reveal" });
|
tabId = result.current ? store.openTabInFront(workspaceKey, result.current) : null;
|
||||||
tabId = sheetTabId ? store.openSheetAsTab(workspaceKey, sheetTabId) : null;
|
|
||||||
});
|
});
|
||||||
expect(tabId).not.toBeNull();
|
expect(tabId).not.toBeNull();
|
||||||
expect(result.current).toBe(false);
|
expect(result.current).toBeNull();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
store.closeTab(workspaceKey, tabId ?? "");
|
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 type { Theme } from "@/styles/theme";
|
||||||
import { useAgentFactsLine } from "@/panels/agent-facts-line";
|
import { useAgentFactsLine } from "@/panels/agent-facts-line";
|
||||||
import { getPanelManifest } from "@/panels/panel-manifest";
|
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 { SubagentRow } from "./select";
|
||||||
import type { ArchiveFinishedStatus } from "./use-archive-finished";
|
import type { ArchiveFinishedStatus } from "./use-archive-finished";
|
||||||
import {
|
import {
|
||||||
@@ -40,10 +42,10 @@ export interface SubagentsTrackProps {
|
|||||||
onOpenSubagent: (address: AgentAddress) => void;
|
onOpenSubagent: (address: AgentAddress) => void;
|
||||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => 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
|
* The sheet a row opens, or null when it opens as a tab. A sheet replaces the list in place and
|
||||||
* close, so the list stays open; a tab replaces the list's pane, so choosing it ends the list.
|
* 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;
|
onArchiveSubagent: (address: AgentAddress) => void;
|
||||||
onArchiveFinished?: () => void;
|
onArchiveFinished?: () => void;
|
||||||
archiveFinishedStatus?: ArchiveFinishedStatus;
|
archiveFinishedStatus?: ArchiveFinishedStatus;
|
||||||
@@ -71,13 +73,14 @@ export function SubagentsTrack({
|
|||||||
rows,
|
rows,
|
||||||
onOpenSubagent,
|
onOpenSubagent,
|
||||||
onOpenProviderSubagent,
|
onOpenProviderSubagent,
|
||||||
opensInSheet,
|
sheetTabFor,
|
||||||
onArchiveSubagent,
|
onArchiveSubagent,
|
||||||
onArchiveFinished,
|
onArchiveFinished,
|
||||||
archiveFinishedStatus = IDLE_ARCHIVE_FINISHED_STATUS,
|
archiveFinishedStatus = IDLE_ARCHIVE_FINISHED_STATUS,
|
||||||
onDetachSubagent,
|
onDetachSubagent,
|
||||||
}: SubagentsTrackProps): ReactElement | null {
|
}: SubagentsTrackProps): ReactElement | null {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const sheetPanePage = useSheetPanePage();
|
||||||
|
|
||||||
const isArchivingFinished = archiveFinishedStatus.kind === "archiving";
|
const isArchivingFinished = archiveFinishedStatus.kind === "archiving";
|
||||||
const isArchiveFinishedFailed = archiveFinishedStatus.kind === "failed";
|
const isArchiveFinishedFailed = archiveFinishedStatus.kind === "failed";
|
||||||
@@ -88,9 +91,12 @@ export function SubagentsTrack({
|
|||||||
const pill = buildSubagentPillPresentation(t, rows);
|
const pill = buildSubagentPillPresentation(t, rows);
|
||||||
const finishedCount = countFinishedSubagents(rows);
|
const finishedCount = countFinishedSubagents(rows);
|
||||||
const showArchiveFinished = finishedCount > 0 || isArchivingFinished || isArchiveFinishedFailed;
|
const showArchiveFinished = finishedCount > 0 || isArchivingFinished || isArchiveFinishedFailed;
|
||||||
|
const sheets = rows.map(sheetTabFor);
|
||||||
|
const pages = sheets.flatMap((sheet) => (sheet ? [sheetPanePage(sheet)] : []));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComposerTrackPill
|
<ComposerTrackPill
|
||||||
|
pages={pages}
|
||||||
testID="subagents-track-header"
|
testID="subagents-track-header"
|
||||||
segments={pill.segments}
|
segments={pill.segments}
|
||||||
accessibilityLabel={pill.accessibilityLabel}
|
accessibilityLabel={pill.accessibilityLabel}
|
||||||
@@ -105,14 +111,14 @@ export function SubagentsTrack({
|
|||||||
/>
|
/>
|
||||||
</ComposerTrackActions>
|
</ComposerTrackActions>
|
||||||
) : null}
|
) : null}
|
||||||
{rows.map((row) => (
|
{rows.map((row, index) => (
|
||||||
<SubagentsTrackRow
|
<SubagentsTrackRow
|
||||||
key={row.kind === "paseo" ? `${row.address.serverId}:${row.id}` : row.id}
|
key={row.kind === "paseo" ? `${row.address.serverId}:${row.id}` : row.id}
|
||||||
row={row}
|
row={row}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
onOpenSubagent={onOpenSubagent}
|
onOpenSubagent={onOpenSubagent}
|
||||||
onOpenProviderSubagent={onOpenProviderSubagent}
|
onOpenProviderSubagent={onOpenProviderSubagent}
|
||||||
opensInSheet={opensInSheet(row)}
|
sheetTabId={sheets[index]?.tab.tabId ?? null}
|
||||||
onArchiveSubagent={onArchiveSubagent}
|
onArchiveSubagent={onArchiveSubagent}
|
||||||
onDetachSubagent={onDetachSubagent}
|
onDetachSubagent={onDetachSubagent}
|
||||||
/>
|
/>
|
||||||
@@ -184,7 +190,8 @@ interface SubagentsTrackRowProps {
|
|||||||
row: SubagentRow;
|
row: SubagentRow;
|
||||||
onOpenSubagent: (address: AgentAddress) => void;
|
onOpenSubagent: (address: AgentAddress) => void;
|
||||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => 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;
|
onArchiveSubagent: (address: AgentAddress) => void;
|
||||||
onDetachSubagent?: (address: AgentAddress) => void;
|
onDetachSubagent?: (address: AgentAddress) => void;
|
||||||
}
|
}
|
||||||
@@ -194,7 +201,7 @@ function SubagentsTrackRow({
|
|||||||
row,
|
row,
|
||||||
onOpenSubagent,
|
onOpenSubagent,
|
||||||
onOpenProviderSubagent,
|
onOpenProviderSubagent,
|
||||||
opensInSheet,
|
sheetTabId,
|
||||||
onArchiveSubagent,
|
onArchiveSubagent,
|
||||||
onDetachSubagent,
|
onDetachSubagent,
|
||||||
}: SubagentsTrackRowProps): ReactElement {
|
}: SubagentsTrackRowProps): ReactElement {
|
||||||
@@ -269,7 +276,7 @@ function SubagentsTrackRow({
|
|||||||
<ComposerTrackRow
|
<ComposerTrackRow
|
||||||
accessibilityLabel={displayLabel}
|
accessibilityLabel={displayLabel}
|
||||||
testID={`subagents-track-row-${row.id}`}
|
testID={`subagents-track-row-${row.id}`}
|
||||||
closeOnSelect={!opensInSheet}
|
opensPage={sheetTabId ?? undefined}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
>
|
>
|
||||||
{renderRow}
|
{renderRow}
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ function appendRun<TGroup>(input: {
|
|||||||
const run = createRun(input.calls, input.isSealed);
|
const run = createRun(input.calls, input.isSealed);
|
||||||
const host = createHost(run);
|
const host = createHost(run);
|
||||||
input.output.push(host);
|
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: {
|
export function prepareGroupedHistory<TGroup>(input: {
|
||||||
@@ -180,7 +180,8 @@ export function groupLiveToolCalls<TGroup>(input: {
|
|||||||
if (hostPlacement === "head") {
|
if (hostPlacement === "head") {
|
||||||
head.push(createHost(run));
|
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));
|
liveGroups.set(run.id, input.buildGroup(run));
|
||||||
}
|
}
|
||||||
pending = [];
|
pending = [];
|
||||||
@@ -217,19 +218,13 @@ export function groupLiveToolCalls<TGroup>(input: {
|
|||||||
historyGroupUpdatesByHostId: EMPTY_GROUPS,
|
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);
|
const groupsByHostId = new Map(input.history.groupsByHostId);
|
||||||
let historyGroupUpdatesByHostId: Map<string, TGroup> | null = null;
|
let historyGroupUpdatesByHostId: Map<string, TGroup> | null = null;
|
||||||
for (const [id, group] of liveGroups) {
|
for (const [id, group] of liveGroups) {
|
||||||
groupsByHostId.set(id, group);
|
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 ??= new Map();
|
||||||
historyGroupUpdatesByHostId.set(id, group);
|
historyGroupUpdatesByHostId.set(id, group);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ import { BottomSheetScrollView, type BottomSheetScrollViewMethods } from "@gorho
|
|||||||
import { Wrench, X } from "lucide-react-native";
|
import { Wrench, X } from "lucide-react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { ToolCallSheetModal, useToolCallSheetContextBridge } from "@/components/tool-call-sheet";
|
||||||
|
import { useToolCallExpansionContextBridge } from "@/tool-calls/expansion-context";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
interface OverviewToolCallGroupSheetProps {
|
interface OverviewToolCallGroupSheetProps {
|
||||||
@@ -29,7 +33,12 @@ export function OverviewToolCallGroupSheet({
|
|||||||
}: OverviewToolCallGroupSheetProps) {
|
}: OverviewToolCallGroupSheetProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const scrollRef = useRef<BottomSheetScrollViewMethods>(null);
|
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({
|
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||||
visible,
|
visible,
|
||||||
onClose,
|
onClose,
|
||||||
|
|||||||
@@ -110,11 +110,7 @@ describe("tool call detail-level projection", () => {
|
|||||||
preparedHistory: prepared,
|
preparedHistory: prepared,
|
||||||
});
|
});
|
||||||
expect(single.head).toEqual([firstCall]);
|
expect(single.head).toEqual([firstCall]);
|
||||||
expect(single.groupsByHostId.get(firstCall.id)?.run).toMatchObject({
|
expect(single.groupsByHostId.size).toBe(0);
|
||||||
calls: [firstCall],
|
|
||||||
latest: firstCall,
|
|
||||||
isSealed: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const grouped = project({
|
const grouped = project({
|
||||||
level: "overview",
|
level: "overview",
|
||||||
@@ -142,18 +138,11 @@ describe("tool call detail-level projection", () => {
|
|||||||
expect(result.groupsByHostId.get("1")?.isLoading).toBe(true);
|
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 call = toolCall("1", { type: "shell", command: "one" }, { status: "running" });
|
||||||
const result = project({ level: "overview", head: [call], isTurnActive: true });
|
const result = project({ level: "overview", head: [call], isTurnActive: true });
|
||||||
const group = result.groupsByHostId.get(call.id);
|
expect(result.head).toEqual([call]);
|
||||||
if (!group) {
|
expect(result.groupsByHostId.size).toBe(0);
|
||||||
throw new Error("Expected an overview group");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(group).toMatchObject({
|
|
||||||
isLoading: true,
|
|
||||||
summary: { commandCount: 1 },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps an active overview group on its latest call until a visible boundary arrives", () => {
|
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] });
|
const result = project({ level: "overview", head: [singleCall, plan, speak] });
|
||||||
|
|
||||||
expect(result.head).toEqual([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(0);
|
||||||
expect(result.groupsByHostId.size).toBe(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -529,12 +517,58 @@ it.each([false, true])(
|
|||||||
});
|
});
|
||||||
expect(result.head).toEqual(autoExpandReasoning ? [thought] : []);
|
expect(result.head).toEqual(autoExpandReasoning ? [thought] : []);
|
||||||
expect(result.groupsByHostId.get(call.id)?.run.calls).toEqual(
|
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 PanelIconProps,
|
||||||
type PanelPresentation,
|
type PanelPresentation,
|
||||||
} from "@/panels/panel-registry";
|
} from "@/panels/panel-registry";
|
||||||
import { ensurePanelsRegistered } from "@/panels/register-panels";
|
|
||||||
import {
|
import {
|
||||||
getTerminalProfileIcon,
|
getTerminalProfileIcon,
|
||||||
resolveTerminalProfiles,
|
resolveTerminalProfiles,
|
||||||
@@ -109,7 +108,6 @@ export function useWorkspaceTabLaunchCatalog(input: {
|
|||||||
const { config } = useDaemonConfig(serverId);
|
const { config } = useDaemonConfig(serverId);
|
||||||
const plugins = useInstalledPlugins();
|
const plugins = useInstalledPlugins();
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
ensurePanelsRegistered();
|
|
||||||
|
|
||||||
const launchSelection = useCallback(
|
const launchSelection = useCallback(
|
||||||
(selection: NewTabSelection) => (destination: WorkspaceTabLaunchDestination) => {
|
(selection: NewTabSelection) => (destination: WorkspaceTabLaunchDestination) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
DEFAULT_PANE_ID,
|
DEFAULT_PANE_ID,
|
||||||
findPaneById,
|
findPaneById,
|
||||||
findPaneContainingTab,
|
findPaneContainingTab,
|
||||||
|
selectSheetTab,
|
||||||
useWorkspaceLayoutStore,
|
useWorkspaceLayoutStore,
|
||||||
type WorkspaceTabPlacement,
|
type WorkspaceTabPlacement,
|
||||||
} from "@/stores/workspace-layout-store";
|
} 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.
|
// The sheet is already a single replaceable slot, so it needs no preview tab.
|
||||||
if (
|
if (
|
||||||
existing ||
|
existing ||
|
||||||
store.opensInSheet({ workspaceKey: input.workspaceKey, target: input.target, intent: "reveal" })
|
selectSheetTab(store, { workspaceKey: input.workspaceKey, target: input.target }) !== null
|
||||||
) {
|
) {
|
||||||
return openPreferredWorkspaceTarget(input);
|
return openPreferredWorkspaceTarget(input);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export function useBottomSheetModal(): never {
|
|||||||
export function useBottomSheet(): never {
|
export function useBottomSheet(): never {
|
||||||
throw new Error("useBottomSheet is unavailable in tests");
|
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 BottomSheetModalInternalProvider = PassThrough;
|
||||||
export const INITIAL_CONTAINER_LAYOUT = {
|
export const INITIAL_CONTAINER_LAYOUT = {
|
||||||
height: 0,
|
height: 0,
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
// The package ships untranspiled Flow, which the unit project cannot parse. Insets are a device
|
import { createContext, useContext } from "react";
|
||||||
// fact no test asserts on, so they are zero.
|
|
||||||
|
// 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;
|
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 useSafeAreaFrame = () => ({ x: 0, y: 0, width: 0, height: 0 });
|
||||||
export const SafeAreaProvider = ({ children }: { children?: unknown }) => children;
|
export const SafeAreaProvider = ({ children }: { children?: unknown }) => children;
|
||||||
export const SafeAreaView = ({ 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