mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dcf64a0b8 |
+4
-11
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 0.13.0 - 2026-10-24
|
||||
## 0.13.0-beta.2 - 2026-10-23
|
||||
|
||||
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||
|
||||
@@ -23,7 +23,7 @@ paseo host add 'relay://...' --label laptop
|
||||
|
||||
### Improved
|
||||
|
||||
- Changed sidebar workspaces to sort by Created, with Last prompt and Manual under Display → Sort ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771))
|
||||
- Changed sidebar workspaces to sort by Created, with Last prompt under Display → Sort; workspaces no longer drag to reorder ([#7635](https://github.com/getpaseo/paseo/pull/7635))
|
||||
- Changed long project groups in the sidebar to show seven workspaces plus any that need attention or are working, with Show more for the rest ([#7635](https://github.com/getpaseo/paseo/pull/7635))
|
||||
- Changed Command Center agent and workspace results to show the same icons and status as tabs and the sidebar ([#7690](https://github.com/getpaseo/paseo/pull/7690))
|
||||
- Changed Manual metadata generation to use only the models you chose, without falling back to the defaults ([#7611](https://github.com/getpaseo/paseo/pull/7611))
|
||||
@@ -32,7 +32,6 @@ paseo host add 'relay://...' --label laptop
|
||||
- Changed Explorer tabs on phones to keep full labels and scroll, with Close moved to the long-press menu ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- Changed relay connections to require the daemon password when the daemon has one; re-add relay hosts saved without it ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch))
|
||||
- Kept the sidebar open on phones when you cancel Search, Import session or Add project ([#7693](https://github.com/getpaseo/paseo/pull/7693))
|
||||
- Updated pinned ACP agents to their latest releases ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck))
|
||||
- Removed the legacy terminal renderer; iOS and Android always use the native terminal renderer ([#7639](https://github.com/getpaseo/paseo/pull/7639))
|
||||
- Changed usage rows to say whether a percentage is used or remaining, in the app's language ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
|
||||
- Changed an agent's report and its finish notification to reach the parent agent as one message ([#7561](https://github.com/getpaseo/paseo/pull/7561))
|
||||
@@ -49,11 +48,10 @@ paseo host add 'relay://...' --label laptop
|
||||
- Added translated plugin listings through `translations` in `paseo-plugin.json` and `OVERVIEW.zh-CN.md` ([#7373](https://github.com/getpaseo/paseo/pull/7373))
|
||||
- Added `Markdown` to `@getpaseo/plugin/client/ui` to render Markdown with Paseo's renderer ([#7579](https://github.com/getpaseo/paseo/pull/7579))
|
||||
- Added inline SVG and component icons to every plugin `icon` ([#7368](https://github.com/getpaseo/paseo/pull/7368))
|
||||
- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point, including `useRevealedText`, still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger))
|
||||
- Changed every plugin UI import to `@getpaseo/plugin/client/ui`; the previous entry point still works and is deprecated ([#7474](https://github.com/getpaseo/paseo/pull/7474))
|
||||
- Deprecated the panel `context` and `agentId` props and `addWorkspacePanel`'s `context` field; use `params.agentId` and `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
|
||||
- Changed turn-ended hooks to send an empty `timeline`; fetch recent history with `timeline.refetch` instead ([#7477](https://github.com/getpaseo/paseo/pull/7477))
|
||||
- Fixed plugin header buttons missing on phones and narrow windows; they now appear in the workspace ··· menu ([#7630](https://github.com/getpaseo/paseo/pull/7630))
|
||||
- Fixed reopened ACP plugin agents showing an empty chat ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream))
|
||||
- Fixed notification timeline transformers failing to load with "invalid item type: notification" ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
|
||||
|
||||
### Fixed
|
||||
@@ -68,11 +66,6 @@ paseo host add 'relay://...' --label laptop
|
||||
- Fixed workspace diff stats and merged-PR auto-archive missing updates while no app was showing the workspace ([#7681](https://github.com/getpaseo/paseo/pull/7681))
|
||||
- Fixed the `/` and `@` picker on iPhone covering the composer and hiding its best match behind the keyboard ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77))
|
||||
- Fixed reopening a workspace marked unread on a phone leaving its agent stuck loading ([#7608](https://github.com/getpaseo/paseo/pull/7608))
|
||||
- Fixed a new workspace's pin or title disappearing from the sidebar until the next update ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024))
|
||||
- Fixed Claude background command notifications appearing twice after a daemon restart ([#7737](https://github.com/getpaseo/paseo/pull/7737))
|
||||
- Fixed voice replies skipping later sentences with OpenAI-compatible speech servers ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian))
|
||||
- Fixed Cursor usage showing bonus spend as overage instead of the quota percentages ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia))
|
||||
- Fixed the Android tablet taskbar covering the sidebar's Add project, Hosts, Help and Settings ([#7768](https://github.com/getpaseo/paseo/pull/7768))
|
||||
- Fixed Command Center jumping back to the selected result while you scroll ([#7688](https://github.com/getpaseo/paseo/pull/7688))
|
||||
- Fixed Claude system context appearing as an assistant message in imported or restored chats ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
|
||||
- Fixed saved workspace tabs being lost when leaving or reloading the app right after it loads ([#7547](https://github.com/getpaseo/paseo/pull/7547))
|
||||
@@ -80,7 +73,7 @@ paseo host add 'relay://...' --label laptop
|
||||
- Fixed Add image failing with "Failed to select image" on Android after changing the font size ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
|
||||
- Fixed popovers, tooltips and context menus on Android opening one status bar below their trigger ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- Fixed the active Explorer tab being invisible in the light theme ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- Fixed Android showing "Host not found" for untrusted certificates, and TLS advice for hostnames that don't resolve ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701))
|
||||
- Fixed Android showing "Host not found" instead of a TLS error for untrusted certificates ([#7429](https://github.com/getpaseo/paseo/pull/7429))
|
||||
- Fixed the keyboard covering focused Settings fields on Android ([#7362](https://github.com/getpaseo/paseo/pull/7362))
|
||||
- Fixed Pull or Push appearing twice in the compact Changes menu ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy))
|
||||
- Fixed Jump to tab and Jump to workspace saving shortcuts that don't end in a number ([#7365](https://github.com/getpaseo/paseo/pull/7365))
|
||||
|
||||
+4
-11
@@ -1,6 +1,6 @@
|
||||
# 更新日志
|
||||
|
||||
## 0.13.0 - 2026-10-24
|
||||
## 0.13.0-beta.2 - 2026-10-23
|
||||
|
||||
折叠屏手机布局、可将工作委派给其他机器的 Agent、插件文件面板,以及插件翻译。
|
||||
|
||||
@@ -23,7 +23,7 @@ paseo host add 'relay://...' --label laptop
|
||||
|
||||
### 改进
|
||||
|
||||
- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”或“手动” ([#7635](https://github.com/getpaseo/paseo/pull/7635), [#7771](https://github.com/getpaseo/paseo/pull/7771))
|
||||
- 侧边栏工作区改为按“创建时间”排序,可在“显示”→“排序”中选择“最近提示词”;工作区不再支持拖动排序 ([#7635](https://github.com/getpaseo/paseo/pull/7635))
|
||||
- 侧边栏中较长的项目分组现在显示七个工作区,以及所有需要处理或正在运行的工作区,其余的可通过“显示更多”查看 ([#7635](https://github.com/getpaseo/paseo/pull/7635))
|
||||
- Command Center 中的 Agent 和工作区结果现在显示与标签页和侧边栏相同的图标和状态 ([#7690](https://github.com/getpaseo/paseo/pull/7690))
|
||||
- 手动元数据生成现在只使用你选择的模型,不再回退到默认模型 ([#7611](https://github.com/getpaseo/paseo/pull/7611))
|
||||
@@ -32,7 +32,6 @@ paseo host add 'relay://...' --label laptop
|
||||
- 手机上的 Explorer 标签页保留完整标签并可滚动,关闭操作移到了长按菜单中 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- 如果 daemon 设置了密码,relay 连接现在需要该密码;请重新添加保存时未带密码的 relay 主机 ([#7668](https://github.com/getpaseo/paseo/pull/7668) by [@nickmaglowsch](https://github.com/nickmaglowsch))
|
||||
- 在手机上取消搜索、导入会话或添加项目时,侧边栏保持打开 ([#7693](https://github.com/getpaseo/paseo/pull/7693))
|
||||
- 已固定版本的 ACP Agent 更新到最新版本 ([#7781](https://github.com/getpaseo/paseo/pull/7781) by [@JunWeiUp](https://github.com/JunWeiUp), [@LouisDeconinck](https://github.com/LouisDeconinck))
|
||||
- 移除了旧版终端渲染器;iOS 和 Android 始终使用原生终端渲染器 ([#7639](https://github.com/getpaseo/paseo/pull/7639))
|
||||
- 用量行现在用应用的语言说明百分比是已用还是剩余 ([#7376](https://github.com/getpaseo/paseo/pull/7376) by [@michaelmwu](https://github.com/michaelmwu))
|
||||
- Agent 的报告和完成通知现在合并为一条消息发送给父 Agent ([#7561](https://github.com/getpaseo/paseo/pull/7561))
|
||||
@@ -49,11 +48,10 @@ paseo host add 'relay://...' --label laptop
|
||||
- 新增插件列表翻译,可通过 `paseo-plugin.json` 中的 `translations` 和 `OVERVIEW.zh-CN.md` 提供 ([#7373](https://github.com/getpaseo/paseo/pull/7373))
|
||||
- `@getpaseo/plugin/client/ui` 新增 `Markdown`,可使用 Paseo 的渲染器渲染 Markdown ([#7579](https://github.com/getpaseo/paseo/pull/7579))
|
||||
- 所有插件 `icon` 新增对内联 SVG 和组件图标的支持 ([#7368](https://github.com/getpaseo/paseo/pull/7368))
|
||||
- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口(包括 `useRevealedText`)仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474), [#7780](https://github.com/getpaseo/paseo/pull/7780) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev), [@mcowger](https://github.com/mcowger))
|
||||
- 所有插件 UI 导入改为 `@getpaseo/plugin/client/ui`;原入口仍可使用,但已弃用 ([#7474](https://github.com/getpaseo/paseo/pull/7474))
|
||||
- 弃用面板的 `context` 和 `agentId` 属性,以及 `addWorkspacePanel` 的 `context` 字段;请改用 `params.agentId` 和 `addAgentPanel` ([#7685](https://github.com/getpaseo/paseo/pull/7685))
|
||||
- turn-ended hook 现在发送空的 `timeline`;请改用 `timeline.refetch` 获取最近的历史记录 ([#7477](https://github.com/getpaseo/paseo/pull/7477))
|
||||
- 修复了插件标题栏按钮在手机和窄窗口中不显示的问题;它们现在显示在工作区的 ··· 菜单中 ([#7630](https://github.com/getpaseo/paseo/pull/7630))
|
||||
- 修复了重新打开的 ACP 插件 Agent 聊天为空的问题 ([#7717](https://github.com/getpaseo/paseo/pull/7717) by [@nistream](https://github.com/nistream))
|
||||
- 修复了 notification 时间线转换器加载失败并报错 "invalid item type: notification" 的问题 ([#7582](https://github.com/getpaseo/paseo/pull/7582) by [@Gavinchen92](https://github.com/Gavinchen92))
|
||||
|
||||
### 修复
|
||||
@@ -68,11 +66,6 @@ paseo host add 'relay://...' --label laptop
|
||||
- 修复了没有应用显示工作区时,工作区 diff 统计和已合并 PR 的自动归档不更新的问题 ([#7681](https://github.com/getpaseo/paseo/pull/7681))
|
||||
- 修复了 iPhone 上 `/` 和 `@` 选择器遮挡输入框、并把最佳匹配项藏在键盘后面的问题 ([#7679](https://github.com/getpaseo/paseo/pull/7679) by [@masz77](https://github.com/masz77))
|
||||
- 修复了在手机上重新打开标记为未读的工作区后 Agent 一直加载的问题 ([#7608](https://github.com/getpaseo/paseo/pull/7608))
|
||||
- 修复了新工作区的置顶或标题在下次更新前从侧边栏消失的问题 ([#7758](https://github.com/getpaseo/paseo/pull/7758) by [@stv1024](https://github.com/stv1024))
|
||||
- 修复了 daemon 重启后 Claude 后台命令通知出现两次的问题 ([#7737](https://github.com/getpaseo/paseo/pull/7737))
|
||||
- 修复了使用 OpenAI 兼容语音服务时语音回复跳过后续句子的问题 ([#7756](https://github.com/getpaseo/paseo/pull/7756) by [@grueneisfabian](https://github.com/grueneisfabian))
|
||||
- 修复了 Cursor 用量将赠送额度消费显示为超额、而不是配额百分比的问题 ([#7778](https://github.com/getpaseo/paseo/pull/7778) by [@kbitz](https://github.com/kbitz), [@stackia](https://github.com/stackia))
|
||||
- 修复了 Android 平板任务栏遮挡侧边栏中添加项目、主机、帮助和设置的问题 ([#7768](https://github.com/getpaseo/paseo/pull/7768))
|
||||
- 修复了滚动时 Command Center 跳回所选结果的问题 ([#7688](https://github.com/getpaseo/paseo/pull/7688))
|
||||
- 修复了导入或恢复的聊天中 Claude 系统上下文显示为助手消息的问题 ([#7590](https://github.com/getpaseo/paseo/pull/7590) by [@khevamann](https://github.com/khevamann), [@rogerluan](https://github.com/rogerluan))
|
||||
- 修复了应用加载后立即离开或重新加载时已保存的工作区标签页丢失的问题 ([#7547](https://github.com/getpaseo/paseo/pull/7547))
|
||||
@@ -80,7 +73,7 @@ paseo host add 'relay://...' --label laptop
|
||||
- 修复了 Android 上更改字体大小后添加图片失败并提示“选择图片失败”的问题 ([#7546](https://github.com/getpaseo/paseo/pull/7546) by [@RRD-dev](https://github.com/RRD-dev), [@idoyana](https://github.com/idoyana), [@luoph](https://github.com/luoph))
|
||||
- 修复了 Android 上弹出框、提示和上下文菜单在触发元素下方偏移一个状态栏高度打开的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- 修复了浅色主题中当前 Explorer 标签页不可见的问题 ([#7589](https://github.com/getpaseo/paseo/pull/7589))
|
||||
- 修复了 Android 上不受信任的证书显示 "Host not found"、无法解析的主机名显示 TLS 建议的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429), [#7701](https://github.com/getpaseo/paseo/pull/7701))
|
||||
- 修复了 Android 上不受信任的证书显示 "Host not found" 而不是 TLS 错误的问题 ([#7429](https://github.com/getpaseo/paseo/pull/7429))
|
||||
- 修复了 Android 上键盘遮挡已聚焦的设置字段的问题 ([#7362](https://github.com/getpaseo/paseo/pull/7362))
|
||||
- 修复了紧凑 Changes 菜单中 Pull 或 Push 出现两次的问题 ([#7355](https://github.com/getpaseo/paseo/pull/7355) by [@az33zy](https://github.com/az33zy))
|
||||
- 修复了“跳转到标签”和“跳转到 workspace”可以保存不以数字结尾的快捷键的问题 ([#7365](https://github.com/getpaseo/paseo/pull/7365))
|
||||
|
||||
+3
-49
@@ -25,54 +25,8 @@ Reload releases the old runtime before resuming its durable session: an idle pro
|
||||
still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the
|
||||
replacement. Once closure succeeds, a failed resume leaves the durable agent closed and retryable.
|
||||
|
||||
Idle resumable runtimes are collected after one hour without activity, checked once a minute.
|
||||
Collection releases the process and preserves the agent record, conversation, relationships, and
|
||||
provider session. The next prompt resumes that session. Set `agents.idleRuntimeCleanup: false` in
|
||||
host config to disable automatic collection. The host setting applies immediately; config-file edits
|
||||
apply on reload. Disabling invalidates pending safety checks and close admission. A close already
|
||||
started finishes. Re-enabling keeps the existing idle timestamps.
|
||||
|
||||
Foreground runs, permissions and responses, queued deliveries, lifecycle operations, and working
|
||||
managed or native descendants prevent collection. Outstanding local or remote finish subscriptions
|
||||
also protect their caller, including registration, recovery and notification dispatch acknowledgment.
|
||||
An idle managed child can still own background work.
|
||||
|
||||
The daemon baseline always applies. An optional provider `canStopRuntime()` adds a veto: false,
|
||||
errors and timeouts retain ownership, while true cannot override daemon blockers. Without a provider
|
||||
check, the baseline decides collection after the same one-hour idle period. Once protected work
|
||||
finishes, a fresh idle hour starts. There is no deadline that overrides a veto.
|
||||
|
||||
The `idle-runtime-cleanup/` module owns the clocks, eligibility policy and sweep lifetime.
|
||||
AgentManager supplies captured runtime capabilities and owns atomic revalidation and shutdown.
|
||||
Finish subscriptions expose caller protection through their public capability; the collector never
|
||||
reads their watches or durable records.
|
||||
|
||||
The collector rechecks daemon state after asynchronous probes and event draining, in the existing
|
||||
close/load lifecycle lane. A prompt admitted before closure prevents collection. A prompt arriving
|
||||
after closure starts waits for release and resumes the saved session.
|
||||
|
||||
Provider checks follow the signals their runtimes expose:
|
||||
|
||||
- Claude uses its process-local background roster and pending completion notifications. A fresh
|
||||
process starts empty. CLI 2.1.292 or newer is known to support the roster; older binaries must
|
||||
demonstrate support by emitting one. Unknown versions stay resident until then.
|
||||
- Codex queries background terminals for loaded threads, including native children, and protects
|
||||
pending provider requests. App servers without the terminal-list RPC stay resident.
|
||||
- OpenCode checks active executions and descendants. V1 checks PTYs; v2 also checks shell and
|
||||
inbox inventories. The shared server remains leased while a session vetoes collection.
|
||||
- Pi checks streaming, compaction, pending replies, and adapter-owned operations. Known native
|
||||
subagent signals also protect their parent through the daemon's subagent store.
|
||||
- OMP additionally requires `get_state.isSettled: true` and `hasPendingAsyncWork: false`.
|
||||
These protect jobs with unsuppressed completion delivery, pending deliveries, queued result
|
||||
injection, and admitted or scheduled continuations that can re-wake the session. Older OMP
|
||||
runtimes without either field stay resident. These signals are not an inventory of arbitrary
|
||||
processes.
|
||||
- ACP checks adapter-owned foreground turns, permissions, buffered user messages, and running
|
||||
ACP terminals, including quiet terminals. Exited or failed-to-spawn terminal records do not
|
||||
prevent collection. This does not inspect processes launched internally by the provider.
|
||||
|
||||
These checks do not discover arbitrary detached processes or work hidden by third-party
|
||||
extensions. A provider plugin adds its own veto through the [runtime safety contract](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
|
||||
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
|
||||
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
|
||||
|
||||
A provider runtime can still die on its own — crash, OOM kill, host suspend. Work the agent parked
|
||||
inside that process dies with it: Claude Code's background Bash shells, `Monitor` watches, and
|
||||
@@ -288,7 +242,7 @@ parentAgentId === thisAgent.id AND !archivedAt
|
||||
|
||||
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
|
||||
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Every subagent pane links back to its parent (`packages/app/src/subagents/parent/`): a pill above the composer when there is one, a parent box in the composer's place when there is not. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
|
||||
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
|
||||
|
||||
|
||||
@@ -498,11 +498,6 @@ Validate and apply it inside the provider; core does not know your option shape.
|
||||
See [provider options](custom-providers.md#provider-options) for configuration and
|
||||
merge semantics.
|
||||
|
||||
Providers add an optional veto to daemon baseline cleanup through the per-session
|
||||
[runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
|
||||
The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins;
|
||||
the plugin bridge transports that query through the existing provider request/event protocol.
|
||||
|
||||
Agent refresh closes the current provider session and opens it again with current configuration and
|
||||
persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the
|
||||
daemon launch from `connect` and the per-session env and MCP servers from `session.open`. There is
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||
|
||||
Connect your daemon to another machine, then ask an agent to delegate work there:
|
||||
|
||||
`sh
|
||||
|
||||
paseo host add 192.168.1.20:6767 --label studio
|
||||
|
||||
paseo host add 'ssh://me@build-box' --label build-box
|
||||
|
||||
paseo host add 'relay://...' --label laptop
|
||||
|
||||
`
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,15 +0,0 @@
|
||||
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||
|
||||
Connect your daemon to another machine, then ask an agent to delegate work there:
|
||||
|
||||
`sh
|
||||
|
||||
paseo host add 192.168.1.20:6767 --label studio
|
||||
|
||||
paseo host add 'ssh://me@build-box' --label build-box
|
||||
|
||||
paseo host add 'relay://...' --label laptop
|
||||
|
||||
`
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,15 +0,0 @@
|
||||
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||
|
||||
Connect your daemon to another machine, then ask an agent to delegate work there:
|
||||
|
||||
`sh
|
||||
|
||||
paseo host add 192.168.1.20:6767 --label studio
|
||||
|
||||
paseo host add 'ssh://me@build-box' --label build-box
|
||||
|
||||
paseo host add 'relay://...' --label laptop
|
||||
|
||||
`
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -1,15 +0,0 @@
|
||||
Foldable phone layouts, agents that delegate work to other machines, plugin file panels, and plugin translations.
|
||||
|
||||
Connect your daemon to another machine, then ask an agent to delegate work there:
|
||||
|
||||
`sh
|
||||
|
||||
paseo host add 192.168.1.20:6767 --label studio
|
||||
|
||||
paseo host add 'ssh://me@build-box' --label build-box
|
||||
|
||||
paseo host add 'relay://...' --label laptop
|
||||
|
||||
`
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-DZOPOf81PkUxdD65BrfkTZhs6/DkfDWCFZ/SU+QrZbg=
|
||||
sha256-n6m3m0541/mBbMN1xrh/2uBCRQc62yicK1cdvyyUmmU=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.0-beta.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.0-beta.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -35695,7 +35695,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.0-beta.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37961,12 +37961,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.0-beta.2",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@getpaseo/server": "0.13.0",
|
||||
"@getpaseo/client": "0.13.0-beta.2",
|
||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
||||
"@getpaseo/server": "0.13.0-beta.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -38213,10 +38213,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.0-beta.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@getpaseo/relay": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
||||
"@getpaseo/relay": "0.13.0-beta.2",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38228,7 +38228,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -38472,7 +38472,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.0-beta.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -42124,7 +42124,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.0-beta.2",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -42153,15 +42153,15 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.0-beta.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"i18next": "^26.3.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@getpaseo/client": "0.13.0-beta.2",
|
||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.2.3",
|
||||
@@ -42171,8 +42171,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@getpaseo/client": "0.13.0-beta.2",
|
||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
||||
"react": ">=19.1.0 <20",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -42194,7 +42194,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.0-beta.2",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -42221,7 +42221,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.0-beta.2",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.4.0",
|
||||
"@noble/curves": "^2.4.0",
|
||||
@@ -42442,16 +42442,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.0-beta.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.13.0",
|
||||
"@getpaseo/highlight": "0.13.0",
|
||||
"@getpaseo/plugin": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@getpaseo/relay": "0.13.0",
|
||||
"@getpaseo/client": "0.13.0-beta.2",
|
||||
"@getpaseo/highlight": "0.13.0-beta.2",
|
||||
"@getpaseo/plugin": "0.13.0-beta.2",
|
||||
"@getpaseo/protocol": "0.13.0-beta.2",
|
||||
"@getpaseo/relay": "0.13.0-beta.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -43372,7 +43372,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.0-beta.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -44982,7 +44982,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.0-beta.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.0-beta.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
async function readFileLinkTooltip(page: Page): Promise<void> {
|
||||
const link = page.getByRole("link", { name: "the guide", exact: true });
|
||||
await link.hover();
|
||||
await expect(page.getByRole("tooltip")).toContainText("guide.md");
|
||||
}
|
||||
|
||||
async function leaveFileLink(page: Page): Promise<void> {
|
||||
await page.mouse.move(5, 5);
|
||||
await expect(page.getByRole("tooltip")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function openLinkedFile(page: Page): Promise<void> {
|
||||
await page.getByRole("link", { name: "the guide", exact: true }).click();
|
||||
await expect(page.getByTestId("markdown-preview-frame")).toContainText("Example guide");
|
||||
await leaveFileLink(page);
|
||||
}
|
||||
|
||||
async function returnToAgent(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Tooltip regression", exact: true }).click();
|
||||
await expect(page.getByRole("link", { name: "the guide", exact: true })).toBeVisible();
|
||||
await leaveFileLink(page);
|
||||
}
|
||||
|
||||
async function switchToFileTab(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "guide.md", exact: true }).click();
|
||||
await expect(page.getByTestId("markdown-preview-frame")).toContainText("Example guide");
|
||||
await leaveFileLink(page);
|
||||
}
|
||||
|
||||
test("opening an assistant file link dismisses its tooltip over the file tab", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
testInfo.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "tooltip-5811-",
|
||||
title: "Tooltip regression",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: "Read [the guide](./guide.md).",
|
||||
mockStreamingAssistantIntervalMs: 5,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(agent.cwd, "guide.md"),
|
||||
"# Example guide\n\n" + "Plain paragraph.\n\n".repeat(150),
|
||||
);
|
||||
await openAgentRoute(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Show the file link.");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
|
||||
await readFileLinkTooltip(page);
|
||||
await leaveFileLink(page);
|
||||
await readFileLinkTooltip(page);
|
||||
await openLinkedFile(page);
|
||||
await returnToAgent(page);
|
||||
await readFileLinkTooltip(page);
|
||||
await switchToFileTab(page);
|
||||
await returnToAgent(page);
|
||||
await readFileLinkTooltip(page);
|
||||
await leaveFileLink(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1415,14 +1415,12 @@ test("collapsing and expanding restores the real horizontal scroll offset", asyn
|
||||
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ settingsKey, fontSize }) => {
|
||||
const settings = JSON.parse(localStorage.getItem(settingsKey) ?? "{}");
|
||||
if (settings.codeFontSize !== undefined) {
|
||||
if (localStorage.getItem(settingsKey)) {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(
|
||||
settingsKey,
|
||||
JSON.stringify({
|
||||
...settings,
|
||||
theme: "dark",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
|
||||
@@ -99,16 +99,6 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents"
|
||||
await expectSubagentRowVisible(page, child.id);
|
||||
});
|
||||
|
||||
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();
|
||||
const childSheet = sheet(page, child.title);
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
await expect(childSheet.getByTestId("composer-parent-agent-pill")).toHaveCount(0);
|
||||
await childSheet.getByTestId("parent-agent-callout-open").click();
|
||||
await expect(childSheet).toHaveCount(0);
|
||||
await expectOpenTabCount(page, 1);
|
||||
});
|
||||
|
||||
await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => {
|
||||
await page.getByTestId(`subagents-track-row-${child.id}`).click();
|
||||
await expectSheetAtSnap(page, child.title);
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
submitMessage,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
function userMessages(page: Page) {
|
||||
return page.getByTestId("user-message");
|
||||
}
|
||||
|
||||
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
|
||||
await submitMessage(page, text);
|
||||
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
||||
await expectComposerDraft(page, "");
|
||||
}
|
||||
|
||||
async function pressInComposer(page: Page, key: string) {
|
||||
await composerLocator(page).press(key);
|
||||
}
|
||||
|
||||
test.describe("Composer recall", () => {
|
||||
test("Up and Down walk sent messages from an empty composer", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-recall-",
|
||||
title: "Composer recall",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await sendAndWaitForMessage(page, "fix the auth bug", 1);
|
||||
await sendAndWaitForMessage(page, "add tests", 2);
|
||||
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "fix the auth bug");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "add tests");
|
||||
await pressInComposer(page, "ArrowDown");
|
||||
await expectComposerDraft(page, "");
|
||||
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await typeIntoFocusedComposer(page, " for login");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
|
||||
await pressInComposer(page, "Enter");
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a recalled slash message does not open the command picker", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-recall-slash-",
|
||||
title: "Composer recall slash",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await sendAndWaitForMessage(page, "/tdd", 1);
|
||||
|
||||
const popover = page.getByTestId("composer-autocomplete-popover").filter({ visible: true });
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "/tdd");
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await typeIntoFocusedComposer(page, "x");
|
||||
await expect(popover.first()).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -229,143 +229,6 @@ test("captures the desktop import sheet and command-center entry", async ({ page
|
||||
});
|
||||
});
|
||||
|
||||
test("large import lists keep mounted rows bounded and remain scrollable", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const fixtureDirectory = path.join(claudeConfigDirectory, "projects", "virtualization-fixture");
|
||||
await mkdir(fixtureDirectory, { recursive: true });
|
||||
try {
|
||||
const newest = Date.now();
|
||||
for (let index = 0; index < 200; index += 1) {
|
||||
const id = `virtualized-${String(index).padStart(3, "0")}`;
|
||||
const file = path.join(fixtureDirectory, `${id}.jsonl`);
|
||||
await writeFile(
|
||||
file,
|
||||
`${JSON.stringify({
|
||||
type: "user",
|
||||
sessionId: id,
|
||||
cwd: index % 2 === 0 ? scenario.projectRoot : scenario.worktreeDirectory,
|
||||
message: {
|
||||
role: "user",
|
||||
content: `Virtualized session ${index}${index % 3 === 0 ? " with a longer preview that wraps onto a second line in a compact sheet" : ""}`,
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
const timestamp = new Date(newest - index * 10);
|
||||
await utimes(file, timestamp, timestamp);
|
||||
}
|
||||
for (const width of [1280, 390]) {
|
||||
const flow = new ImportSessionFlow(page);
|
||||
await flow.openWorkspace(scenario.project.workspaceId, { width, height: 844 });
|
||||
await flow.openGlobally();
|
||||
const rows = page.locator('[data-testid^="import-session-session-"]');
|
||||
const scrollToEnd = async (end = true) => {
|
||||
// Scroll the body's real overflow element. Virtual rows need not exist yet.
|
||||
await rows.first().evaluate((row, atEnd) => {
|
||||
let element = row.parentElement;
|
||||
while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY)) {
|
||||
element = element.parentElement;
|
||||
}
|
||||
if (!element) throw new Error("Import list has no scroll owner");
|
||||
element.scrollTop = atEnd ? element.scrollHeight : 0;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
}, end);
|
||||
};
|
||||
for (let pageIndex = 0; pageIndex < 3; pageIndex += 1) {
|
||||
const loadMore = page.getByTestId("import-session-load-more");
|
||||
await expect(async () => {
|
||||
await scrollToEnd();
|
||||
await expect(loadMore).toBeInViewport({ timeout: 500 });
|
||||
}).toPass({ timeout: 10_000 });
|
||||
await loadMore.click();
|
||||
if (pageIndex < 2) await expect(loadMore).toBeEnabled();
|
||||
}
|
||||
await expect(async () => {
|
||||
await scrollToEnd();
|
||||
await expect(
|
||||
page.getByTestId("import-session-session-claude-virtualized-199"),
|
||||
).toBeInViewport({ timeout: 500 });
|
||||
}).toPass({ timeout: 10_000 });
|
||||
await expect.poll(() => rows.count()).toBeLessThan(100);
|
||||
await page.screenshot({ path: testInfo.outputPath(`virtualized-${width}.png`) });
|
||||
if (width === 1280) {
|
||||
const row = (index: number) =>
|
||||
page.getByTestId(
|
||||
`import-session-session-claude-virtualized-${String(index).padStart(3, "0")}`,
|
||||
);
|
||||
await expect(async () => {
|
||||
await scrollToEnd(false);
|
||||
await expect(row(0)).toBeInViewport({ timeout: 500 });
|
||||
}).toPass({ timeout: 10_000 });
|
||||
await row(0).focus();
|
||||
for (let index = 1; index <= 30; index += 1) {
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(row(index)).toBeFocused();
|
||||
}
|
||||
for (let index = 29; index >= 15; index -= 1) {
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(row(index)).toBeFocused();
|
||||
}
|
||||
const list = page.getByTestId("import-session-list");
|
||||
const scrollWithKey = async (event: "scroll" | "scrollend", key: string) => {
|
||||
const listener = await list.evaluateHandle((node, eventName) => {
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
node.addEventListener(eventName, () => resolve(), { once: true });
|
||||
});
|
||||
return { promise };
|
||||
}, event);
|
||||
try {
|
||||
await page.keyboard.press(key);
|
||||
await listener.evaluate((value) => value.promise);
|
||||
} finally {
|
||||
await listener.dispose();
|
||||
}
|
||||
};
|
||||
const beforePageDown = await list.evaluate((node) => node.scrollTop);
|
||||
await scrollWithKey("scroll", "PageDown");
|
||||
await expect
|
||||
.poll(() => list.evaluate((node) => node.scrollTop))
|
||||
.toBeGreaterThan(beforePageDown);
|
||||
await expect(row(15)).toBeFocused();
|
||||
await page.keyboard.press("End");
|
||||
await expect(row(15)).toBeFocused();
|
||||
await expect(row(199)).toBeInViewport();
|
||||
// Home must remain at the first row after the native page scroll ends.
|
||||
await scrollWithKey("scrollend", "Home");
|
||||
await expect(row(0)).toBeInViewport();
|
||||
await expect(row(15)).toBeFocused();
|
||||
|
||||
await page.keyboard.press("End");
|
||||
await expect(row(199)).toBeInViewport();
|
||||
await row(199).focus();
|
||||
await scrollWithKey("scroll", "PageUp");
|
||||
await page.keyboard.press("Home");
|
||||
await expect(row(0)).toBeInViewport();
|
||||
await scrollWithKey("scrollend", "End");
|
||||
await expect(row(199)).toBeInViewport();
|
||||
await expect(row(199)).toBeFocused();
|
||||
await testInfo.attach("keyboard-scroll-owner", {
|
||||
body: JSON.stringify(
|
||||
await list.evaluate((node) => ({
|
||||
focusedRow: document.activeElement?.getAttribute("data-testid"),
|
||||
scrollTop: node.scrollTop,
|
||||
scrollHeight: node.scrollHeight,
|
||||
clientHeight: node.clientHeight,
|
||||
})),
|
||||
),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await page.screenshot({ path: testInfo.outputPath("virtualized-keyboard-end.png") });
|
||||
await page.keyboard.press("Home");
|
||||
await expect(row(0)).toBeInViewport();
|
||||
}
|
||||
await flow.close();
|
||||
}
|
||||
} finally {
|
||||
await rm(fixtureDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
const outputPath = testInfo.outputPath(name);
|
||||
await page.screenshot({ path: outputPath, fullPage: true });
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
delayBrowserAgentCreatedStatus,
|
||||
openNewWorkspaceComposer,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
// The creation response carries the descriptor captured when the workspace was provisioned. With an
|
||||
// initial agent it is sent only after the agent starts, so a pin made in between (by a plugin's
|
||||
// workspace.created hook, another client, or the CLI) reaches the app first as a live update. The
|
||||
// late response must not put the old, unpinned row back.
|
||||
|
||||
function pinnedRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
}
|
||||
|
||||
async function findCreatedWorkspaceId(seeded: SeededWorkspace): Promise<string> {
|
||||
let createdId: string | undefined;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { entries } = await seeded.client.fetchWorkspaces({
|
||||
filter: { projectId: seeded.projectId },
|
||||
});
|
||||
createdId = entries.find((entry) => entry.id !== seeded.workspaceId)?.id;
|
||||
return createdId;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeTruthy();
|
||||
return createdId!;
|
||||
}
|
||||
|
||||
// Samples every frame, so a row that leaves Pinned and comes back on a later update still counts.
|
||||
async function watchPinnedRow(page: Page, testId: string): Promise<() => Promise<number>> {
|
||||
await page.evaluate((rowTestId) => {
|
||||
const state = { missingFrames: 0, stopped: false };
|
||||
(window as unknown as { __pinnedRowWatch: typeof state }).__pinnedRowWatch = state;
|
||||
const sample = () => {
|
||||
if (state.stopped) return;
|
||||
const section = document.querySelector('[data-testid="sidebar-pinned-section"]');
|
||||
if (!section?.querySelector(`[data-testid="${rowTestId}"]`)) state.missingFrames += 1;
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
}, testId);
|
||||
return () =>
|
||||
page.evaluate(() => {
|
||||
const state = (
|
||||
window as unknown as { __pinnedRowWatch: { missingFrames: number; stopped: boolean } }
|
||||
).__pinnedRowWatch;
|
||||
state.stopped = true;
|
||||
return state.missingFrames;
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("New workspace live pin", () => {
|
||||
test("keeps a pin made before the creation response arrives", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const creation = await delayBrowserAgentCreatedStatus(page);
|
||||
const seeded = await seedWorkspace({ repoPrefix: "new-workspace-live-pin-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, seeded);
|
||||
await submitNewWorkspacePrompt(page, "Pin me while I start");
|
||||
|
||||
await creation.waitForCreateRequest();
|
||||
// The response is held here, carrying the descriptor from before the pin.
|
||||
await creation.waitForDelayedCreatedStatus();
|
||||
const workspaceId = await findCreatedWorkspaceId(seeded);
|
||||
const rowTestId = pinnedRowTestId(workspaceId);
|
||||
|
||||
await seeded.client.setWorkspacePinned(workspaceId, true);
|
||||
const pinnedRow = page.getByTestId("sidebar-pinned-section").getByTestId(rowTestId);
|
||||
await expect(pinnedRow).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const stopWatching = await watchPinnedRow(page, rowTestId);
|
||||
creation.release();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable({
|
||||
timeout: 30_000,
|
||||
});
|
||||
// Leave time for the released response to be merged and rendered.
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
expect(await stopWatching()).toBe(0);
|
||||
await expect(pinnedRow).toBeVisible();
|
||||
} finally {
|
||||
creation.release();
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -136,8 +136,7 @@ test.describe("real provider subagent timelines", () => {
|
||||
panel.getByText("Start chatting with this agent...", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
// A provider subagent has no composer; its callout is the way back to the parent.
|
||||
await page.getByTestId("parent-agent-callout-open").click();
|
||||
await page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click();
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fork } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { expect, type BrowserContext, type Locator, type Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { z } from "zod";
|
||||
import { test } from "../support/fixtures";
|
||||
import { expectVisibleAgentSurfacesIdle } from "../support/helpers/agent-stream";
|
||||
@@ -34,218 +34,8 @@ const ROLLOUT_QUESTION = "Which rollout should we use?";
|
||||
const SUCCESS_QUESTION = "What success criteria should we use?";
|
||||
const REPO_URL_QUESTION = "What is the GitHub private repo URL to push to?";
|
||||
const COMMIT_MESSAGE_QUESTION = "What should the first commit message be?";
|
||||
const LINKED_QUESTION = "Sign in at https://example.com/login, then follow the setup guide. Done?";
|
||||
|
||||
function selectedText(page: Page) {
|
||||
return page.evaluate(() => window.getSelection()?.toString().trim());
|
||||
}
|
||||
|
||||
const CHECKS_QUESTION = "Which checks should run?";
|
||||
const DONE_DESCRIPTION = "Logs are at https://example.com/logs if sign-in fails.";
|
||||
const LINT_DESCRIPTION = "Rules: https://example.com/lint";
|
||||
|
||||
async function openLinkedQuestions(page: Page, context: BrowserContext) {
|
||||
await context.route("https://example.com/**", (route) =>
|
||||
route.fulfill({ status: 200, body: "ok" }),
|
||||
);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "question-option-links-",
|
||||
title: "Question option links e2e",
|
||||
initialPrompt: "Emit synthetic questions with links.",
|
||||
});
|
||||
await openAgentRoute(page, session);
|
||||
await waitForQuestionPrompt(page, 120_000);
|
||||
return { session, card: page.getByTestId("question-form-card").first() };
|
||||
}
|
||||
|
||||
async function dragAcross(page: Page, target: Locator) {
|
||||
const bounds = await target.boundingBox();
|
||||
if (!bounds) throw new Error("Text is not visible");
|
||||
const y = bounds.y + bounds.height / 2;
|
||||
await page.mouse.move(bounds.x + 1, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + bounds.width - 1, y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
async function clickLinkAndExpectPage(context: BrowserContext, link: Locator, url: string) {
|
||||
const popup = context.waitForEvent("page");
|
||||
await link.click();
|
||||
await expect.poll(async () => (await popup).url()).toBe(url);
|
||||
}
|
||||
|
||||
async function expectChecked(option: Locator, checked: boolean) {
|
||||
await expect(option).toHaveAttribute("aria-checked", String(checked));
|
||||
}
|
||||
|
||||
test.describe("Question option text", () => {
|
||||
test("links in an option label or description open without answering", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const { session, card } = await openLinkedQuestions(page, context);
|
||||
try {
|
||||
const done = card.getByRole("radio", { name: "Done" });
|
||||
const notYet = card.getByRole("radio", { name: "Not yet" });
|
||||
|
||||
await clickLinkAndExpectPage(
|
||||
context,
|
||||
notYet.getByRole("link", { name: "status" }),
|
||||
"https://example.com/status",
|
||||
);
|
||||
await clickLinkAndExpectPage(
|
||||
context,
|
||||
done.getByRole("link", { name: "https://example.com/logs" }),
|
||||
"https://example.com/logs",
|
||||
);
|
||||
|
||||
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
|
||||
await expectChecked(done, false);
|
||||
await expectChecked(notYet, false);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("selecting description text never answers, and the label still does", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const { session, card } = await openLinkedQuestions(page, context);
|
||||
try {
|
||||
const done = card.getByRole("radio", { name: "Done" });
|
||||
const description = done.getByTestId("question-form-option-description");
|
||||
|
||||
await dragAcross(page, description);
|
||||
await expect.poll(() => selectedText(page)).toBe(DONE_DESCRIPTION);
|
||||
await expectChecked(done, false);
|
||||
|
||||
await description.getByText("Logs are at", { exact: false }).dblclick({
|
||||
position: { x: 4, y: 4 },
|
||||
});
|
||||
await expect.poll(() => selectedText(page)).toBe("Logs");
|
||||
await expectCurrentQuestion(page, { index: 1, total: 2, question: LINKED_QUESTION });
|
||||
await expectChecked(done, false);
|
||||
|
||||
await done.getByText("Done", { exact: true }).click();
|
||||
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
||||
await openQuestion(page, { index: 1, total: 2 });
|
||||
await expectChecked(done, true);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("double-clicking a single-select label answers once", async ({ page, context }) => {
|
||||
test.setTimeout(180_000);
|
||||
const { session, card } = await openLinkedQuestions(page, context);
|
||||
try {
|
||||
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).dblclick();
|
||||
|
||||
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
||||
await expectChecked(card.getByRole("checkbox", { name: "Lint" }), false);
|
||||
await expectChecked(card.getByRole("checkbox", { name: "Typecheck" }), false);
|
||||
await openQuestion(page, { index: 1, total: 2 });
|
||||
await expectChecked(card.getByRole("radio", { name: "Done" }), true);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("multi-select description links and selection leave the option unchanged", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const { session, card } = await openLinkedQuestions(page, context);
|
||||
try {
|
||||
await card.getByRole("radio", { name: "Done" }).getByText("Done", { exact: true }).click();
|
||||
await expectCurrentQuestion(page, { index: 2, total: 2, question: CHECKS_QUESTION });
|
||||
|
||||
const lint = card.getByRole("checkbox", { name: "Lint" });
|
||||
const typecheck = card.getByRole("checkbox", { name: "Typecheck" });
|
||||
const description = lint.getByTestId("question-form-option-description");
|
||||
|
||||
await clickLinkAndExpectPage(
|
||||
context,
|
||||
description.getByRole("link", { name: "https://example.com/lint" }),
|
||||
"https://example.com/lint",
|
||||
);
|
||||
await expectChecked(lint, false);
|
||||
|
||||
await dragAcross(page, description);
|
||||
await expect.poll(() => selectedText(page)).toBe(LINT_DESCRIPTION);
|
||||
await expectChecked(lint, false);
|
||||
|
||||
await description.getByText("Rules:", { exact: false }).dblclick({
|
||||
position: { x: 4, y: 4 },
|
||||
});
|
||||
await expect.poll(() => selectedText(page)).toBe("Rules");
|
||||
await expectChecked(lint, false);
|
||||
|
||||
await typecheck.getByText("Typecheck", { exact: true }).dblclick();
|
||||
await expectChecked(typecheck, true);
|
||||
await lint.getByText("Lint", { exact: true }).click();
|
||||
await expectChecked(lint, true);
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Question prompt pagination", () => {
|
||||
test("question links open without answering, and the question text is selectable", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await context.route("https://example.com/**", (route) =>
|
||||
route.fulfill({ status: 200, body: "ok" }),
|
||||
);
|
||||
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "question-links-",
|
||||
title: "Question links e2e",
|
||||
initialPrompt: "Emit synthetic questions with links.",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await waitForQuestionPrompt(page, 120_000);
|
||||
|
||||
const card = page.getByTestId("question-form-card").first();
|
||||
const question = card.getByTestId("question-form-current-question");
|
||||
await expect(question).toHaveText(LINKED_QUESTION);
|
||||
await expect(question.getByRole("link", { name: "https://example.com/login" })).toBeVisible();
|
||||
await expect(question.getByRole("link", { name: "the setup guide" })).toBeVisible();
|
||||
|
||||
const bounds = await question.boundingBox();
|
||||
if (!bounds) throw new Error("Question text is not visible");
|
||||
const y = bounds.y + bounds.height / 2;
|
||||
await page.mouse.move(bounds.x + 1, y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds.x + bounds.width - 1, y, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(() => selectedText(page)).toBe(LINKED_QUESTION);
|
||||
|
||||
const popup = context.waitForEvent("page");
|
||||
await question.getByRole("link", { name: "the setup guide" }).click();
|
||||
await expect.poll(async () => (await popup).url()).toBe("https://example.com/setup");
|
||||
|
||||
await expect(page.getByTestId("question-form-card")).toHaveCount(1);
|
||||
for (const option of ["Done", "Not yet"]) {
|
||||
await expect(card.getByRole("radio", { name: option })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows one question at a time with numbered navigation", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { readFile, mkdir, rename, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
@@ -29,8 +25,6 @@ import {
|
||||
removeHostFromHostPage,
|
||||
addDirectHostFromSettings,
|
||||
expectHostOnlineWithoutError,
|
||||
expectIdleAgentRelease,
|
||||
setIdleAgentRelease,
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
test.describe("Settings host page", () => {
|
||||
@@ -143,100 +137,6 @@ test.describe("Settings host page", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("idle agent release changes live on one host and stays saved without restarting", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const primaryServerId = getServerId();
|
||||
const daemon = await startIsolatedHostDaemon("srv_e2e_idle_agent_release");
|
||||
const client = await connectDaemonClient<DaemonClient>({
|
||||
clientIdPrefix: "idle-release-settings",
|
||||
port: daemon.port,
|
||||
});
|
||||
const originalPid = (await client.getDaemonStatus()).pid;
|
||||
const expectLiveSavedSetting = async (enabled: boolean) => {
|
||||
const saved = JSON.parse(await readFile(path.join(daemon.paseoHome, "config.json"), "utf8"));
|
||||
expect(saved.agents.idleRuntimeCleanup).toBe(enabled);
|
||||
expect((await client.getDaemonConfig()).config.idleRuntimeCleanup).toBe(enabled);
|
||||
expect((await client.reloadDaemonConfig()).restartRequiredPaths).toEqual([]);
|
||||
expect((await client.getDaemonStatus()).pid).toBe(originalPid);
|
||||
};
|
||||
const hosts = [
|
||||
{
|
||||
serverId: primaryServerId,
|
||||
label: TEST_HOST_LABEL,
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
},
|
||||
{
|
||||
serverId: daemon.serverId,
|
||||
label: "Idle release host",
|
||||
endpoint: `127.0.0.1:${daemon.port}`,
|
||||
},
|
||||
];
|
||||
const openHostAgentsInFreshApp = async (serverId: string) => {
|
||||
await seedSavedSettingsHosts(page, hosts);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
await selectSettingsHost(page, serverId);
|
||||
await openHostSection(page, serverId, "agents");
|
||||
};
|
||||
try {
|
||||
await test.step("idle agent release starts on and turns off", async () => {
|
||||
await openHostAgentsInFreshApp(daemon.serverId);
|
||||
await expectIdleAgentRelease(page, true);
|
||||
await setIdleAgentRelease(page, false);
|
||||
await expectLiveSavedSetting(false);
|
||||
});
|
||||
|
||||
await test.step("a fresh app reads the saved setting from the same running host", async () => {
|
||||
await openHostAgentsInFreshApp(daemon.serverId);
|
||||
await expectIdleAgentRelease(page, false);
|
||||
await expectLiveSavedSetting(false);
|
||||
await setIdleAgentRelease(page, true);
|
||||
await expectLiveSavedSetting(true);
|
||||
await setIdleAgentRelease(page, false);
|
||||
});
|
||||
|
||||
await test.step("other hosts keep their own setting", async () => {
|
||||
await selectSettingsHost(page, primaryServerId);
|
||||
await openHostSection(page, primaryServerId, "agents");
|
||||
await expectIdleAgentRelease(page, true);
|
||||
await expectLiveSavedSetting(false);
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a failed idle release save stays visible and retry applies it live", async ({ page }) => {
|
||||
const daemon = await startIsolatedHostDaemon("srv_e2e_idle_release_failure");
|
||||
const configPath = path.join(daemon.paseoHome, "config.json");
|
||||
try {
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{ serverId: daemon.serverId, label: "Failure host", endpoint: `127.0.0.1:${daemon.port}` },
|
||||
]);
|
||||
await page.reload();
|
||||
await openSettings(page);
|
||||
await openHostSection(page, daemon.serverId, "agents");
|
||||
await expectIdleAgentRelease(page, true);
|
||||
// A real filesystem failure, with the daemon and RPC path intact.
|
||||
await rename(configPath, `${configPath}.saved`);
|
||||
await mkdir(configPath);
|
||||
await page.getByRole("switch", { name: "Release idle agents" }).click();
|
||||
await expect(page.getByTestId("host-page-idle-runtime-cleanup-error")).toContainText(
|
||||
"Failed to read",
|
||||
);
|
||||
await expectIdleAgentRelease(page, true);
|
||||
await rm(configPath, { recursive: true });
|
||||
await rename(`${configPath}.saved`, configPath);
|
||||
await setIdleAgentRelease(page, false);
|
||||
await expect(page.getByTestId("host-page-idle-runtime-cleanup-error")).toHaveCount(0);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("navigating to /settings/hosts/[serverId] redirects to the connections section", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -6,10 +6,7 @@ import {
|
||||
addDirectHostFromSettings,
|
||||
clickSettingsBackToWorkspace,
|
||||
openSettingsHostSection,
|
||||
openSettingsSection,
|
||||
selectSettingsHost,
|
||||
} from "../support/helpers/settings";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
||||
|
||||
@@ -47,14 +44,6 @@ test.describe("Settings host selection", () => {
|
||||
await openSettings(page);
|
||||
|
||||
await openSettingsHostSection(page, remoteDaemon.serverId, "connections");
|
||||
|
||||
await selectSettingsHost(page, getServerId());
|
||||
await openSettingsSection(page, "appearance");
|
||||
await openSettingsHostSection(page, getServerId(), "connections");
|
||||
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, remoteDaemon.serverId, "connections");
|
||||
} finally {
|
||||
await remoteWorkspace.cleanup().catch(() => undefined);
|
||||
await remoteDaemon.close().catch(() => undefined);
|
||||
|
||||
@@ -43,8 +43,6 @@ import {
|
||||
} from "../support/helpers/settings";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
async function openWorkspace(
|
||||
page: import("@playwright/test").Page,
|
||||
@@ -55,74 +53,6 @@ async function openWorkspace(
|
||||
}
|
||||
|
||||
test.describe("Settings sidebar navigation", () => {
|
||||
test("opening Settings closes the composer model selector until it is opened again", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "settings-model-selector-" });
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewChat(page);
|
||||
|
||||
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
|
||||
const popover = page.getByTestId("combobox-desktop-container");
|
||||
await selector.click();
|
||||
await expect(popover).toBeVisible();
|
||||
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+,`);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await page.screenshot({ path: testInfo.outputPath("settings-after-model-selector.png") });
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(selector).toBeVisible();
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await selector.click();
|
||||
await expect(popover).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(popover).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("opening Settings closes an existing chat's model selector until it is opened again", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "settings-existing-model-",
|
||||
title: "Settings model selector",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const selector = page.getByTestId("combined-model-selector").filter({ visible: true });
|
||||
const popover = page.getByTestId("combobox-desktop-container");
|
||||
await selector.click();
|
||||
await expect(popover).toBeVisible();
|
||||
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+,`);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("settings-after-existing-model-selector.png"),
|
||||
});
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(selector).toBeVisible();
|
||||
await expect(popover).toHaveCount(0);
|
||||
|
||||
await selector.click();
|
||||
await expect(popover).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(popover).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
@@ -147,28 +77,6 @@ test.describe("Settings sidebar navigation", () => {
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
test("host choice survives app sections and host screen replacements", async ({ page }) => {
|
||||
const primaryServerId = getServerId();
|
||||
const secondaryServerId = "srv_e2e_settings_secondary";
|
||||
const endpoint = `127.0.0.1:${getE2EDaemonPort()}`;
|
||||
await seedSavedSettingsHosts(page, [
|
||||
{ serverId: primaryServerId, label: "First horse", endpoint },
|
||||
{ serverId: secondaryServerId, label: "Stable horse", endpoint },
|
||||
]);
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await selectSettingsHost(page, secondaryServerId);
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
await openSettingsSection(page, "appearance");
|
||||
await expectSettingsHostPickerLabel(page, "Stable horse");
|
||||
await openSettingsSection(page, "general");
|
||||
await expectSettingsHostPickerLabel(page, "Stable horse");
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
await selectSettingsHost(page, primaryServerId);
|
||||
await openSettingsSection(page, "about");
|
||||
await expectSettingsHostPickerLabel(page, "First horse");
|
||||
});
|
||||
|
||||
test("/h/[serverId]/settings redirects to the host connections section", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await verifyLegacyHostSettingsRedirect(page);
|
||||
@@ -239,42 +147,6 @@ test.describe("Settings sidebar navigation", () => {
|
||||
test.describe("Settings — compact master-detail", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test("the compact tab switcher reopens after Settings hides its workspace", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "settings-tab-switcher-",
|
||||
title: "Compact tab switcher",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const switcher = page.getByTestId("workspace-tab-switcher-trigger").filter({ visible: true });
|
||||
const newAgent = page.getByTestId("workspace-tab-switcher-new-agent");
|
||||
await switcher.click();
|
||||
await expect(newAgent).toBeInViewport();
|
||||
|
||||
// Navigate without dismissing the sheet through its backdrop first.
|
||||
await page
|
||||
.getByTestId("menu-button")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.dispatchEvent("click");
|
||||
await page.getByTestId("sidebar-settings").first().dispatchEvent("click");
|
||||
await expectAppRoute(page, buildSettingsRoute());
|
||||
await expect(newAgent).toHaveCount(0);
|
||||
|
||||
await closeCompactSettings(page);
|
||||
await expect(switcher).toBeVisible();
|
||||
await expect(newAgent).toHaveCount(0);
|
||||
await switcher.click();
|
||||
await expect(newAgent).toBeInViewport();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens compact app and host details and returns through the settings list", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -351,15 +223,6 @@ test.describe("Settings — compact master-detail", () => {
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
await goBackInSettings(page);
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
|
||||
await openSettingsSection(page, "appearance");
|
||||
await goBackInSettings(page);
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
await openSettingsHostSection(page, secondaryServerId, "connections");
|
||||
await goBackInSettings(page);
|
||||
await expectSettingsHostPickerLabel(page, secondaryHostLabel);
|
||||
});
|
||||
|
||||
test("removing the last active host returns to welcome after settings closes", async ({
|
||||
|
||||
@@ -10,16 +10,6 @@ async function pressSettingsToggleShortcut(page: import("@playwright/test").Page
|
||||
await page.keyboard.press(`${modifier}+Comma`);
|
||||
}
|
||||
|
||||
async function openComposerTab(page: import("@playwright/test").Page, agentId: string) {
|
||||
await page
|
||||
.getByTestId(`workspace-tab-agent_${agentId}`)
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
await expectAgentTabActive(page, agentId);
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
|
||||
}
|
||||
|
||||
interface ComposerFrame {
|
||||
at: number;
|
||||
visible: boolean;
|
||||
@@ -37,9 +27,7 @@ async function startComposerFrameCapture(page: import("@playwright/test").Page)
|
||||
|
||||
const sample = () => {
|
||||
if (capture.stopped) return;
|
||||
const root = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-testid="message-input-root"]'),
|
||||
).find((candidate) => candidate.offsetParent !== null);
|
||||
const root = document.querySelector<HTMLElement>('[data-testid="message-input-root"]');
|
||||
const input = root?.querySelector<HTMLTextAreaElement>("textarea");
|
||||
if (root && input) {
|
||||
const style = getComputedStyle(input);
|
||||
@@ -190,8 +178,6 @@ test.describe("Settings toggle tab regression", () => {
|
||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||
await waitForTabBar(page);
|
||||
|
||||
await openComposerTab(page, firstAgent.id);
|
||||
await openComposerTab(page, secondAgent.id);
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await input.fill("Keep this short draft in the composer");
|
||||
const root = page.getByTestId("message-input-root").filter({ visible: true }).first();
|
||||
@@ -208,7 +194,6 @@ test.describe("Settings toggle tab regression", () => {
|
||||
|
||||
const frames = await stopComposerFrameCapture(page);
|
||||
const visibleFrames = frames.filter((frame) => frame.visible);
|
||||
expect(visibleFrames.length, "Capture must observe the visible composer").toBeGreaterThan(0);
|
||||
const tallestIndex = visibleFrames.reduce(
|
||||
(index, frame, candidateIndex) =>
|
||||
frame.rootHeight > visibleFrames[index].rootHeight ? candidateIndex : index,
|
||||
|
||||
@@ -123,7 +123,7 @@ test("projects and pinned chats reorder while ordinary workspaces stay newest fi
|
||||
}
|
||||
});
|
||||
|
||||
async function selectWorkspaceSort(page: Page, name: "Manual" | "Created" | "Last prompt") {
|
||||
async function selectWorkspaceSort(page: Page, name: "Created" | "Last prompt") {
|
||||
await page.getByLabel("Display preferences", { exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: /^Sort/ }).click();
|
||||
await page.getByRole("menuitem", { name, exact: true }).click();
|
||||
@@ -362,60 +362,3 @@ test("compact groups retain active overflow and reveal the current ordered rows"
|
||||
await fixture.seed.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function restorePreBetaOrder(page: Page, projectKey: string, workspaceKeys: string[]) {
|
||||
await page.evaluate(
|
||||
({ projectKey: scope, workspaceKeys: order }) => {
|
||||
localStorage.setItem(
|
||||
"sidebar-project-workspace-order",
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
state: {
|
||||
projectOrder: [scope],
|
||||
pinnedWorkspaceOrder: [],
|
||||
workspaceOrderByProject: { [scope]: order },
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ projectKey, workspaceKeys },
|
||||
);
|
||||
await reconnectSidebar(page);
|
||||
}
|
||||
|
||||
test("Manual restores pre-beta order and keeps drag order across sorting and reloads", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seed = await seedWorkspace({ repoPrefix: "sidebar-manual-" });
|
||||
try {
|
||||
const second = await seed.client.createWorkspace({
|
||||
source: { kind: "directory", path: seed.repoPath, projectId: seed.projectId },
|
||||
title: "New manual workspace",
|
||||
});
|
||||
if (!second.workspace) throw new Error(second.error ?? "Workspace creation failed");
|
||||
const keys = [seed.workspaceId, second.workspace.id].map((id) => `${getServerId()}:${id}`);
|
||||
const ids = keys.map((key) => `sidebar-workspace-row-${key}`);
|
||||
const rows = page.locator(ids.map((id) => `[data-testid="${id}"]`).join(", "));
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await restorePreBetaOrder(page, projectEquivalenceViewKey(seed.projectKey), keys);
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
|
||||
await selectWorkspaceSort(page, "Manual");
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
|
||||
await selectWorkspaceSort(page, "Created");
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
|
||||
await selectWorkspaceSort(page, "Last prompt");
|
||||
await selectWorkspaceSort(page, "Manual");
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
|
||||
await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow);
|
||||
await reconnectSidebar(page);
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual([ids[1], ids[0]]);
|
||||
await quickDragFirstRowAfterSecond(rows, pressWorkspaceRow);
|
||||
await selectWorkspaceSort(page, "Created");
|
||||
await reconnectSidebar(page);
|
||||
await selectWorkspaceSort(page, "Manual");
|
||||
await expect.poll(() => rowTestIds(rows)).toEqual(ids);
|
||||
} finally {
|
||||
await seed.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -72,9 +72,6 @@ async function expectSortingRequiresHostUpdate(page: Page) {
|
||||
).toBeDisabled();
|
||||
await expect(page.getByRole("menuitem", { name: "Created", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("menuitem", { name: "Last prompt", exact: true })).toHaveCount(0);
|
||||
await page.getByRole("menuitem", { name: "Manual", exact: true }).click();
|
||||
await page.getByLabel("Display preferences", { exact: true }).click();
|
||||
await expect(page.getByRole("menuitem", { name: /^Sort/ })).toContainText("Manual");
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
|
||||
@@ -125,29 +125,6 @@ test("opening an already-tabbed subagent reveals its left tab without a duplicat
|
||||
}
|
||||
});
|
||||
|
||||
test("a subagent's parent pill returns to its parent", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "subagent-parent-pill-" });
|
||||
try {
|
||||
const pair = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Refactor auth flow",
|
||||
childTitle: "Write session tests",
|
||||
});
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, { workspaceId: pair.workspaceId, agentId: pair.child.id });
|
||||
|
||||
const pill = page.getByTestId("composer-parent-agent-pill");
|
||||
await expect(pill).toHaveAccessibleName("Back to Refactor auth flow");
|
||||
await pill.click();
|
||||
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${pair.parent.id}`)).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a preferred chat file link still opens beside its right-pane agent", async ({ page }) => {
|
||||
const target = "linked.ts:3";
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -143,7 +143,7 @@ async function configureOverviewToolCalls(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ test("measures an overview heading that becomes loading after its idle mount", a
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
);
|
||||
});
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns";
|
||||
|
||||
async function useChatDisplay(page: Page, chatDisplay: ChatDisplay): Promise<void> {
|
||||
await page.addInitScript((value) => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value }));
|
||||
}, chatDisplay);
|
||||
}
|
||||
|
||||
async function seedAnsweredAgent(title: string) {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "turn-collapse-",
|
||||
title,
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "Why does the chat stop following new output?",
|
||||
});
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Make the fix and run the burst script.");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
return agent;
|
||||
}
|
||||
|
||||
/** Chat rows between the latest turn's summary line and its reply, in document order. */
|
||||
async function rowsBetweenLatestSummaryAndReply(page: Page): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
const rows = [...document.querySelectorAll("[data-message-id]")];
|
||||
const summaryRow = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]"));
|
||||
const replyRow = rows.findLastIndex((row) =>
|
||||
row.querySelector("[data-testid=assistant-message]"),
|
||||
);
|
||||
return replyRow - summaryRow - 1;
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Turn collapse", () => {
|
||||
test("folds each answered turn behind a summary line that opens it", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const agent = await seedAnsweredAgent("Turn collapse");
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const summary = page.getByTestId("turn-summary").last().getByRole("button");
|
||||
await expect(summary).toHaveText(/^Wrote \d+ messages?, edited 1 file/);
|
||||
// Text chunks that stream back to back merge into one message, so the reply holds
|
||||
// at least the stream's last chunk.
|
||||
await expect(page.getByTestId("assistant-message").last()).toHaveText(
|
||||
/\(end of synthetic stream\)$/,
|
||||
);
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
|
||||
await summary.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0);
|
||||
await expect(summary).toBeInViewport();
|
||||
|
||||
await summary.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
# Inline code formatting
|
||||
|
||||
This fixture renders the production `MarkdownRenderer` with plain, bold, italic,
|
||||
combined emphasis, strikethrough and linked code, plus bold code in headings,
|
||||
lists and quotes. Toggle regular/compact text. Inline code uses the theme’s
|
||||
code-to-body size ratio at every heading level; fenced code keeps the code-size
|
||||
token. Bold and links preserve the resulting size. Check the glyphs, not the background height: native backgrounds use
|
||||
line metrics.
|
||||
|
||||
Use a development client rebuilt from this checkout. Follow the temporary entry
|
||||
point and Metro workflow in [status-ring](../status-ring/README.md), importing
|
||||
`MarkdownFormattingRegression` from `./e2e/mobile/markdown-formatting/native`.
|
||||
Open the same Metro URL in a browser to compare web. Restore `index.ts` afterward.
|
||||
|
||||
The package patch contains `InlineCodeFormattingTest`, which exercises actual
|
||||
Android spans in Robolectric native graphics mode, covering draw and measurement,
|
||||
span order, code inside paragraph/heading sizes, bold/italic composition, and
|
||||
links preserving code and emphasized prose fonts.
|
||||
It runs in the existing `test:android` CI job. To run only this regression locally,
|
||||
from `packages/app/android` after Expo prebuild:
|
||||
|
||||
```sh
|
||||
./gradlew :react-native-enriched-markdown:testDebugUnitTest \
|
||||
--tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2
|
||||
```
|
||||
@@ -1,52 +0,0 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Button, ScrollView, Text } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: { flex: 1 },
|
||||
scroll: { backgroundColor: theme.colors.surface0 },
|
||||
content: { padding: 32, paddingTop: 60 },
|
||||
title: { color: theme.colors.foreground },
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const source = [
|
||||
"Plain: `MMMMiiii`",
|
||||
"Bold: **`MMMMiiii`**",
|
||||
"Italic: *`MMMMiiii`*",
|
||||
"Both: ***`MMMMiiii`***",
|
||||
"Strike: ~~`MMMMiiii`~~",
|
||||
"Link: [`MMMMiiii`](https://example.com)",
|
||||
"# Heading **`MMMMiiii`**",
|
||||
"## Heading *`MMMMiiii`*",
|
||||
"### Heading [`MMMMiiii`](https://example.com)",
|
||||
"#### Heading ~~`MMMMiiii`~~",
|
||||
"##### Heading ***`MMMMiiii`***",
|
||||
"###### Heading `MMMMiiii`",
|
||||
"- List **`MMMMiiii`**",
|
||||
"> Quote **`MMMMiiii`**",
|
||||
"Normal **bold** and *italic* prose.",
|
||||
].join("\n\n");
|
||||
|
||||
export default function MarkdownFormattingRegression() {
|
||||
const [compact, setCompact] = useState(false);
|
||||
const toggleCompact = useCallback(() => setCompact((value) => !value), []);
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.title}>Inline code formatting regression</Text>
|
||||
<Button
|
||||
title={compact ? "Use regular text" : "Use compact text"}
|
||||
onPress={toggleCompact}
|
||||
/>
|
||||
<MarkdownRenderer text={source} compact={compact} />
|
||||
</ScrollView>
|
||||
</GestureHandlerRootView>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// Open a connected host with the wide sidebar visible, keyboard closed, and
|
||||
// three-button navigation enabled on an Android tablet. Run against a fresh build:
|
||||
// node packages/app/e2e/mobile/sidebar-system-insets/android.mjs <session> <adb-serial>
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const [, , session, serial] = process.argv;
|
||||
assert(session && serial, "Pass the agent-device session and adb serial");
|
||||
const windowState = execFileSync("adb", ["-s", serial, "shell", "dumpsys", "window"], {
|
||||
encoding: "utf8",
|
||||
timeout: 10_000,
|
||||
});
|
||||
const navBar = windowState.match(/type=navigationBars frame=\[0,(\d+)\]\[\d+,\d+\] visible=true/);
|
||||
assert(navBar, "A visible bottom navigation bar is required");
|
||||
assert(!/type=ime .*visible=true/.test(windowState), "Close the keyboard first");
|
||||
const navigationTop = Number(navBar[1]);
|
||||
const { data } = JSON.parse(
|
||||
execFileSync("agent-device", ["snapshot", "--raw", "--json", "--session", session], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
}),
|
||||
);
|
||||
const focusedApp = windowState.match(/mCurrentFocus=.*? u\d+ ([^/\s]+)\//)?.[1];
|
||||
assert.equal(
|
||||
focusedApp,
|
||||
data.appBundleId,
|
||||
"Snapshot must belong to the focused app on this device",
|
||||
);
|
||||
for (const id of [
|
||||
"sidebar-add-project",
|
||||
"sidebar-hosts-trigger",
|
||||
"sidebar-help",
|
||||
"sidebar-settings",
|
||||
]) {
|
||||
const button = data.nodes.find((node) => node.identifier === id);
|
||||
assert(button?.rect, `${id} must be visible above the system navigation bar`);
|
||||
const bottom = button.rect.y + button.rect.height;
|
||||
assert(bottom <= navigationTop, `${id} ends at ${bottom}, below navigation top ${navigationTop}`);
|
||||
console.log(`PASS: ${id} bottom=${bottom}, navigationTop=${navigationTop}`);
|
||||
}
|
||||
@@ -180,11 +180,6 @@ const test = daemonTest.extend<{
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon, ...extraHosts]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
// New installs collapse finished turns; specs assert on the rows inside them, so they
|
||||
// start from the full chat unless they seed their own settings.
|
||||
if (!localStorage.getItem("@paseo:app-settings")) {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: "full" }));
|
||||
}
|
||||
},
|
||||
{
|
||||
daemon: testDaemon,
|
||||
|
||||
@@ -27,7 +27,7 @@ export class ImportSessionFlow {
|
||||
await expectMobileAgentSidebarVisible(this.page);
|
||||
}
|
||||
await openGlobalNewWorkspaceComposer(this.page);
|
||||
const button = this.page.getByTestId("new-workspace-import-session").filter({ visible: true });
|
||||
const button = this.page.getByTestId("new-workspace-import-session");
|
||||
await expect(button).toHaveAccessibleName("Import session");
|
||||
await expect(button).toBeInViewport();
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export class ImportSessionFlow {
|
||||
await this.openFromNewWorkspace();
|
||||
}
|
||||
async openFromNewWorkspace() {
|
||||
await this.page.getByTestId("new-workspace-import-session").filter({ visible: true }).click();
|
||||
await this.page.getByTestId("new-workspace-import-session").click();
|
||||
await this.expectSheetReady();
|
||||
}
|
||||
async openFromWorkspaceHeader() {
|
||||
@@ -86,29 +86,7 @@ export class ImportSessionFlow {
|
||||
expect((bounds?.x ?? width) + (bounds?.width ?? 1)).toBeLessThanOrEqual(width);
|
||||
}
|
||||
async revealSession(id: string) {
|
||||
const target = this.page.getByTestId(rowTestId(id));
|
||||
if ((await target.count()) === 0) {
|
||||
await this.scrollList(false);
|
||||
await expect(async () => {
|
||||
if ((await target.count()) === 0) await this.scrollList(true);
|
||||
await expect(target).toHaveCount(1, { timeout: 100 });
|
||||
}).toPass({ timeout: 10_000 });
|
||||
}
|
||||
await target.scrollIntoViewIfNeeded();
|
||||
await expect(target).toBeInViewport();
|
||||
}
|
||||
private async scrollList(forward: boolean) {
|
||||
await this.page
|
||||
.locator('[data-testid^="import-session-session-"]')
|
||||
.first()
|
||||
.evaluate((row, advance) => {
|
||||
let element = row.parentElement;
|
||||
while (element && !["auto", "scroll"].includes(getComputedStyle(element).overflowY))
|
||||
element = element.parentElement;
|
||||
if (!element) throw new Error("Import list has no scroll owner");
|
||||
element.scrollTop = advance ? element.scrollTop + element.clientHeight * 0.8 : 0;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
}, forward);
|
||||
await this.page.getByTestId(rowTestId(id)).scrollIntoViewIfNeeded();
|
||||
}
|
||||
async search(query: string) {
|
||||
await this.page.getByTestId("import-session-search").fill(query);
|
||||
@@ -122,8 +100,7 @@ export class ImportSessionFlow {
|
||||
}
|
||||
async loadMore() {
|
||||
await this.page.getByTestId("import-session-load-more").click();
|
||||
await this.revealSession("fixture-root-20");
|
||||
await expect(this.page.getByText("Root session 20", { exact: true })).toBeInViewport();
|
||||
await expect(this.page.getByText("Root session 20", { exact: true })).toBeVisible();
|
||||
await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0);
|
||||
}
|
||||
async retryProvider(provider: string, label: string) {
|
||||
@@ -134,7 +111,6 @@ export class ImportSessionFlow {
|
||||
await expect(this.page.getByRole("progressbar")).toHaveCount(0);
|
||||
}
|
||||
async importSession(id: string) {
|
||||
await this.revealSession(id);
|
||||
await this.page.getByTestId(rowTestId(id)).click();
|
||||
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
@@ -162,7 +138,7 @@ export class ImportSessionFlow {
|
||||
);
|
||||
}
|
||||
async close() {
|
||||
await this.page.getByRole("button", { name: "Close", exact: true }).last().click();
|
||||
await this.page.keyboard.press("Escape");
|
||||
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0);
|
||||
}
|
||||
async expectCommandCenterMatch() {
|
||||
|
||||
@@ -116,7 +116,7 @@ export async function withTimelinePlugin(
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
);
|
||||
});
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -326,25 +326,6 @@ export async function expectHostInjectMcpCard(page: Page): Promise<void> {
|
||||
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
|
||||
}
|
||||
|
||||
function idleAgentReleaseSwitch(page: Page) {
|
||||
return page
|
||||
.getByTestId("host-page-idle-runtime-cleanup-card")
|
||||
.getByRole("switch", { name: "Release idle agents" });
|
||||
}
|
||||
|
||||
export async function expectIdleAgentRelease(page: Page, enabled: boolean): Promise<void> {
|
||||
const toggle = idleAgentReleaseSwitch(page);
|
||||
await expect(toggle).toBeEnabled();
|
||||
await expect(toggle).toHaveAttribute("aria-checked", String(enabled));
|
||||
}
|
||||
|
||||
export async function setIdleAgentRelease(page: Page, enabled: boolean): Promise<void> {
|
||||
const toggle = idleAgentReleaseSwitch(page);
|
||||
await expect(toggle).toHaveAttribute("aria-checked", String(!enabled));
|
||||
await toggle.click();
|
||||
await expectIdleAgentRelease(page, enabled);
|
||||
}
|
||||
|
||||
export async function openHostSection(
|
||||
page: Page,
|
||||
serverId: string,
|
||||
|
||||
@@ -63,9 +63,8 @@ export async function seedSidebarFooterPreferences(
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ key, sidebarFooterItems }) => {
|
||||
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
|
||||
if (settings.sidebarFooterItems) return;
|
||||
localStorage.setItem(key, JSON.stringify({ ...settings, sidebarFooterItems }));
|
||||
if (localStorage.getItem(key)) return;
|
||||
localStorage.setItem(key, JSON.stringify({ sidebarFooterItems }));
|
||||
},
|
||||
{ key: APP_SETTINGS_KEY, sidebarFooterItems: preferences },
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.0-beta.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -40,7 +40,7 @@
|
||||
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
|
||||
"build:apk": "npm --prefix ../.. run build:apk",
|
||||
"build:apk:arm64": "npm --prefix ../.. run build:apk:arm64",
|
||||
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest :react-native-enriched-markdown:testReleaseUnitTest --tests '*InlineCodeFormattingTest' --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
|
||||
"test:android": "expo prebuild --platform android --no-install && cd android && ./gradlew :paseo-native-trace:testReleaseUnitTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
|
||||
@@ -24,7 +24,6 @@ function viewport(root: HTMLElement, capture: Capture) {
|
||||
const handle: StreamViewportHandle = {
|
||||
scrollToBottom() {},
|
||||
prepareForViewportChange() {},
|
||||
holdReadingPosition() {},
|
||||
scrollToMessage(_messageId, occurrence) {
|
||||
if (occurrence) capture.occurrence = occurrence;
|
||||
},
|
||||
|
||||
@@ -265,7 +265,6 @@ describe("useChatOutline", () => {
|
||||
const viewport: StreamViewportHandle = {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
holdReadingPosition: vi.fn(),
|
||||
scrollToMessage,
|
||||
};
|
||||
const viewportRef = { current: viewport };
|
||||
@@ -317,7 +316,6 @@ describe("useChatOutline", () => {
|
||||
current: {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
holdReadingPosition: vi.fn(),
|
||||
scrollToMessage,
|
||||
},
|
||||
};
|
||||
@@ -373,7 +371,6 @@ describe("useChatOutline", () => {
|
||||
const viewport: StreamViewportHandle = {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
holdReadingPosition: vi.fn(),
|
||||
scrollToMessage,
|
||||
};
|
||||
const livePrompt = {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/tim
|
||||
import type { InstalledPlugin } from "@/plugins/types";
|
||||
import {
|
||||
applyStreamEvent,
|
||||
getStreamItemMessageId,
|
||||
hydrateStreamState,
|
||||
type StreamItem,
|
||||
type ToolCallItem,
|
||||
@@ -14,7 +13,7 @@ import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAgentStreamRenderModel } from "./model";
|
||||
import { createStreamPresentation } from "./presentation";
|
||||
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
|
||||
|
||||
const runtime = {
|
||||
paseo: {},
|
||||
@@ -98,11 +97,7 @@ function pluginData(items: StreamItem[]): unknown[] {
|
||||
return items.flatMap((item) => (item.kind === "plugin" ? [item.data] : []));
|
||||
}
|
||||
|
||||
const presentationOptions = {
|
||||
display: "collapseToolCalls" as const,
|
||||
expandedTurnHostIds: new Set<string>(),
|
||||
isTurnActive: true,
|
||||
};
|
||||
const presentationOptions = { level: "overview" as const, isTurnActive: true };
|
||||
|
||||
function streamHarness(transform?: TimelineItemTransform) {
|
||||
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ChatDisplay } from "@/hooks/use-settings";
|
||||
import type { TimelineItemTransform } from "@/plugins/timeline/model";
|
||||
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
|
||||
import {
|
||||
@@ -6,32 +5,23 @@ import {
|
||||
projectToolCallDetailLevel,
|
||||
type PreparedToolCallHistory,
|
||||
type ToolCallDetailLevel,
|
||||
type ToolCallDetailProjection,
|
||||
} from "@/tool-calls/detail-level/projection";
|
||||
import type { StreamItem, UserMessageItem } from "@/types/stream";
|
||||
import { collapseTurns, type TurnCollapse } from "./turn-collapse/model";
|
||||
|
||||
interface PresentationInput {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
transform: TimelineItemTransform | undefined;
|
||||
display: ChatDisplay;
|
||||
/** User messages whose collapsed turn the reader opened. */
|
||||
expandedTurnHostIds: ReadonlySet<string>;
|
||||
level: ToolCallDetailLevel;
|
||||
isTurnActive: boolean;
|
||||
}
|
||||
|
||||
export interface StreamPresentation extends ToolCallDetailProjection {
|
||||
turns: Omit<TurnCollapse, "tail">;
|
||||
/** Find and scrolling address a plugin row by the source it replaces. */
|
||||
export function getStreamItemMessageId(item: StreamItem): string {
|
||||
if (item.kind === "plugin") return item.sourceId ?? item.id;
|
||||
return item.id;
|
||||
}
|
||||
|
||||
const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({
|
||||
tail: [],
|
||||
isLatestTurnOpen: false,
|
||||
expandedHostIds: new Set(),
|
||||
getGroupedCalls: () => undefined,
|
||||
});
|
||||
|
||||
/** Source messages reach plugins before display projection. */
|
||||
export function createStreamPresentation() {
|
||||
const userMessageCache = new WeakMap<UserMessageItem, UserMessageItem>();
|
||||
@@ -57,47 +47,12 @@ export function createStreamPresentation() {
|
||||
let preparedTail: StreamItem[] | undefined;
|
||||
let preparedLevel: ToolCallDetailLevel | undefined;
|
||||
let preparedHistory: PreparedToolCallHistory | null = null;
|
||||
let collapsedSource: StreamItem[] | undefined;
|
||||
let collapsedIsLatestTurnOpen = false;
|
||||
let collapsedExpandedHostIds: ReadonlySet<string> | undefined;
|
||||
let collapsed: TurnCollapse | undefined;
|
||||
|
||||
function presentRows(item: StreamItem): StreamItem[] {
|
||||
return [item.kind === "user_message" ? presentUserMessage(item) : item];
|
||||
}
|
||||
|
||||
function collapse(
|
||||
projection: ToolCallDetailProjection,
|
||||
input: PresentationInput,
|
||||
): StreamPresentation {
|
||||
if (input.display !== "collapseTurns") {
|
||||
return { ...projection, turns: NO_COLLAPSED_TURNS };
|
||||
}
|
||||
// Live text updates reach the head only, so the folded history is reused until the
|
||||
// retained rows, the latest turn's liveness, or the reader's expanded turns change.
|
||||
const isLatestTurnOpen = input.isTurnActive || projection.head.length > 0;
|
||||
if (
|
||||
!collapsed ||
|
||||
collapsedSource !== projection.tail ||
|
||||
collapsedIsLatestTurnOpen !== isLatestTurnOpen ||
|
||||
collapsedExpandedHostIds !== input.expandedTurnHostIds
|
||||
) {
|
||||
collapsed = collapseTurns({
|
||||
tail: projection.tail,
|
||||
isLatestTurnOpen,
|
||||
expandedHostIds: input.expandedTurnHostIds,
|
||||
getGroupedCalls: (itemId) => projection.groupsByHostId.get(itemId)?.run.calls,
|
||||
});
|
||||
collapsedSource = projection.tail;
|
||||
collapsedIsLatestTurnOpen = isLatestTurnOpen;
|
||||
collapsedExpandedHostIds = input.expandedTurnHostIds;
|
||||
}
|
||||
const { tail, ...turns } = collapsed;
|
||||
return { ...projection, tail, turns };
|
||||
}
|
||||
|
||||
return (input: PresentationInput): StreamPresentation => {
|
||||
const level: ToolCallDetailLevel = input.display === "full" ? "detailed" : "overview";
|
||||
return (input: PresentationInput) => {
|
||||
// Retained history is not reprojected or regrouped on each live text update.
|
||||
if (historySource !== input.tail || historyTransform !== input.transform) {
|
||||
historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap<StreamItem>(
|
||||
@@ -111,20 +66,17 @@ export function createStreamPresentation() {
|
||||
const blocks = presentRows(item);
|
||||
head.push(...blocks);
|
||||
}
|
||||
if (preparedTail !== historyRows || preparedLevel !== level) {
|
||||
preparedHistory = prepareToolCallHistory(level, historyRows);
|
||||
if (preparedTail !== historyRows || preparedLevel !== input.level) {
|
||||
preparedHistory = prepareToolCallHistory(input.level, historyRows);
|
||||
preparedTail = historyRows;
|
||||
preparedLevel = level;
|
||||
preparedLevel = input.level;
|
||||
}
|
||||
return collapse(
|
||||
projectToolCallDetailLevel({
|
||||
level,
|
||||
tail: historyRows,
|
||||
head,
|
||||
preparedHistory,
|
||||
isTurnActive: input.isTurnActive,
|
||||
}),
|
||||
input,
|
||||
);
|
||||
return projectToolCallDetailLevel({
|
||||
level: input.level,
|
||||
tail: historyRows,
|
||||
head,
|
||||
preparedHistory,
|
||||
isTurnActive: input.isTurnActive,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -361,7 +361,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
bottomAnchorController.prepareForStickyViewportChange();
|
||||
markNativeViewportSettling();
|
||||
},
|
||||
holdReadingPosition: () => bottomAnchorController.detachByUser(),
|
||||
};
|
||||
viewportRef.current = handle;
|
||||
return () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { withUnistyles } from "react-native-unistyles";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
||||
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
type HistoryStartSettleScheduler,
|
||||
} from "./history-start-settle-scheduler";
|
||||
import { useChatFindSelectedMessageId } from "@/agent-stream/chat-find";
|
||||
import { getStreamItemMessageId } from "./presentation";
|
||||
import { useScrollToMessage } from "./use-scroll-to-message.web";
|
||||
|
||||
interface CreateWebStreamStrategyInput {
|
||||
@@ -1173,11 +1174,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
scheduleStickToBottom();
|
||||
},
|
||||
holdReadingPosition: () => {
|
||||
cancelPendingStickToBottom();
|
||||
setFollowOutput(false);
|
||||
readingAnchor.scroll(scrollContainerRef.current?.scrollTop ?? 0);
|
||||
},
|
||||
scrollToMessage,
|
||||
};
|
||||
viewportRef.current = handle;
|
||||
@@ -1190,7 +1186,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}, [
|
||||
cancelPendingStickToBottom,
|
||||
forceStickToBottom,
|
||||
readingAnchor,
|
||||
scheduleStickToBottom,
|
||||
scrollToMessage,
|
||||
viewportRef,
|
||||
|
||||
@@ -55,8 +55,6 @@ export interface StreamViewportHandle {
|
||||
focus?: () => void;
|
||||
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
|
||||
prepareForViewportChange: () => void;
|
||||
/** Stop following output so the rows on screen stay put while rows below them open or close. */
|
||||
holdReadingPosition: () => void;
|
||||
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem, ToolCallItem } from "@/types/stream";
|
||||
import { collapseTurns, type CollapseTurnsInput } from "./model";
|
||||
|
||||
const at = new Date("2026-01-01T00:00:00.000Z");
|
||||
|
||||
function user(id: string): StreamItem {
|
||||
return { kind: "user_message", id, text: id, timestamp: at };
|
||||
}
|
||||
|
||||
function reply(id: string): StreamItem {
|
||||
return { kind: "assistant_message", id, text: id, timestamp: at };
|
||||
}
|
||||
|
||||
function thought(id: string): StreamItem {
|
||||
return { kind: "thought", id, text: id, timestamp: at, status: "ready" };
|
||||
}
|
||||
|
||||
function shell(id: string): ToolCallItem {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
timestamp: at,
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
provider: "claude",
|
||||
callId: id,
|
||||
name: "Bash",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "shell", command: "ls" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {}) {
|
||||
return collapseTurns({
|
||||
tail,
|
||||
isLatestTurnOpen: false,
|
||||
expandedHostIds: new Set(),
|
||||
getGroupedCalls: () => undefined,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
const ids = (items: StreamItem[]) => items.map((item) => item.id);
|
||||
|
||||
describe("collapseTurns", () => {
|
||||
it("folds a finished turn's work into its user message and keeps the reply", () => {
|
||||
const result = collapse([
|
||||
user("u1"),
|
||||
thought("t1"),
|
||||
reply("early"),
|
||||
shell("c1"),
|
||||
shell("c2"),
|
||||
reply("final"),
|
||||
]);
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "final"]);
|
||||
expect(result.turnsByHostId.get("u1")).toEqual({
|
||||
hostId: "u1",
|
||||
expanded: false,
|
||||
summary: {
|
||||
messageCount: 1,
|
||||
tools: expect.objectContaining({ commandCount: 2 }),
|
||||
},
|
||||
});
|
||||
expect(result.hostIdByFoldedMessageId.get("early")).toBe("u1");
|
||||
});
|
||||
|
||||
it("shows an expanded turn's work as nested rows between the prompt and the reply", () => {
|
||||
const result = collapse([user("u1"), thought("t1"), shell("c1"), reply("final")], {
|
||||
expandedHostIds: new Set(["u1"]),
|
||||
});
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "t1", "c1", "final"]);
|
||||
expect([...result.nestedItemIds]).toEqual(["t1", "c1"]);
|
||||
expect(result.turnsByHostId.get("u1")?.expanded).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the latest turn alone while it is still producing output", () => {
|
||||
const tail = [user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")];
|
||||
|
||||
const result = collapse(tail, { isLatestTurnOpen: true });
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "r1", "u2", "c2", "r2"]);
|
||||
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
it("keeps rows after the reply, and turns with no work before the reply, as they are", () => {
|
||||
const result = collapse([
|
||||
user("u1"),
|
||||
shell("c1"),
|
||||
reply("r1"),
|
||||
shell("after"),
|
||||
user("u2"),
|
||||
reply("r2"),
|
||||
]);
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "r1", "after", "u2", "r2"]);
|
||||
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
it("does not fold a turn that only thought before its reply", () => {
|
||||
const tail = [user("u1"), thought("t1"), reply("r1")];
|
||||
|
||||
const result = collapse(tail);
|
||||
|
||||
expect(result.tail).toBe(tail);
|
||||
expect(result.turnsByHostId.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not fold a turn without a reply or whose prompt is above the loaded history", () => {
|
||||
const tail = [shell("c0"), reply("r0"), user("u1"), shell("c1")];
|
||||
|
||||
const result = collapse(tail);
|
||||
|
||||
expect(result.tail).toBe(tail);
|
||||
expect(result.turnsByHostId.size).toBe(0);
|
||||
});
|
||||
|
||||
it("counts every call a tool-call group row stands for", () => {
|
||||
const group = [shell("c1"), shell("c2"), shell("c3")];
|
||||
const result = collapse([user("u1"), group[0]!, reply("final")], {
|
||||
getGroupedCalls: (itemId) => (itemId === "c1" ? group : undefined),
|
||||
});
|
||||
|
||||
expect(result.turnsByHostId.get("u1")?.summary).toMatchObject({
|
||||
tools: { commandCount: 3 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,122 +0,0 @@
|
||||
import { getStreamItemMessageId, type StreamItem, type ToolCallItem } from "@/types/stream";
|
||||
import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model";
|
||||
|
||||
export interface TurnSummary {
|
||||
messageCount: number;
|
||||
tools: OverviewSummary;
|
||||
}
|
||||
|
||||
export interface CollapsedTurn {
|
||||
/** The user message the turn answers. Its row carries the turn's summary line. */
|
||||
hostId: string;
|
||||
expanded: boolean;
|
||||
summary: TurnSummary;
|
||||
}
|
||||
|
||||
export interface TurnCollapse {
|
||||
tail: StreamItem[];
|
||||
turnsByHostId: ReadonlyMap<string, CollapsedTurn>;
|
||||
/** Rows of expanded turns, which render inside the turn's indent rail. */
|
||||
nestedItemIds: ReadonlySet<string>;
|
||||
/** The turn a folded message belongs to, for surfaces that must reveal it. */
|
||||
hostIdByFoldedMessageId: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
export interface CollapseTurnsInput {
|
||||
tail: StreamItem[];
|
||||
/** The latest turn is still producing output, so it stays unfolded. */
|
||||
isLatestTurnOpen: boolean;
|
||||
expandedHostIds: ReadonlySet<string>;
|
||||
/** The calls a tool-call group row stands for; undefined for a single call. */
|
||||
getGroupedCalls: (itemId: string) => readonly ToolCallItem[] | undefined;
|
||||
}
|
||||
|
||||
const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map();
|
||||
const EMPTY_IDS: ReadonlySet<string> = new Set();
|
||||
const EMPTY_HOSTS: ReadonlyMap<string, string> = new Map();
|
||||
|
||||
function summarizeTurn(
|
||||
items: readonly StreamItem[],
|
||||
getGroupedCalls: CollapseTurnsInput["getGroupedCalls"],
|
||||
): TurnSummary {
|
||||
const calls: ToolCallItem[] = [];
|
||||
let messageCount = 0;
|
||||
for (const item of items) {
|
||||
if (item.kind === "assistant_message") messageCount += 1;
|
||||
else if (item.kind === "tool_call") calls.push(...(getGroupedCalls(item.id) ?? [item]));
|
||||
}
|
||||
return { messageCount, tools: summarizeToolCalls(calls) };
|
||||
}
|
||||
|
||||
function hasWork(summary: TurnSummary): boolean {
|
||||
return summary.messageCount > 0 || Object.values(summary.tools).some((count) => count > 0);
|
||||
}
|
||||
|
||||
function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number {
|
||||
for (let index = end - 1; index >= start; index -= 1) {
|
||||
if (tail[index]?.kind === "assistant_message") return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds each answered turn into its user message: everything the agent did before its last
|
||||
* message collapses into one summary line, and that message stays in the chat. A turn folds
|
||||
* only once it is complete, its user message is loaded, and it sent messages or called tools
|
||||
* before its reply. Thoughts fold with the turn but are not counted.
|
||||
*/
|
||||
export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
const { tail } = input;
|
||||
const output: StreamItem[] = [];
|
||||
const turnsByHostId = new Map<string, CollapsedTurn>();
|
||||
const nestedItemIds = new Set<string>();
|
||||
const hostIdByFoldedMessageId = new Map<string, string>();
|
||||
|
||||
let runStart = 0;
|
||||
const flushRun = (runEnd: number) => {
|
||||
const host = tail[runStart - 1];
|
||||
const isClosed = runEnd < tail.length || !input.isLatestTurnOpen;
|
||||
const replyIndex = findLastAssistantIndex(tail, runStart, runEnd);
|
||||
if (host?.kind !== "user_message" || !isClosed || replyIndex <= runStart) {
|
||||
output.push(...tail.slice(runStart, runEnd));
|
||||
return;
|
||||
}
|
||||
const folded = tail.slice(runStart, replyIndex);
|
||||
const summary = summarizeTurn(folded, input.getGroupedCalls);
|
||||
if (!hasWork(summary)) {
|
||||
output.push(...tail.slice(runStart, runEnd));
|
||||
return;
|
||||
}
|
||||
const expanded = input.expandedHostIds.has(host.id);
|
||||
turnsByHostId.set(host.id, { hostId: host.id, expanded, summary });
|
||||
for (const item of folded) {
|
||||
hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id);
|
||||
if (expanded) nestedItemIds.add(item.id);
|
||||
}
|
||||
if (expanded) output.push(...folded);
|
||||
output.push(...tail.slice(replyIndex, runEnd));
|
||||
};
|
||||
|
||||
for (let index = 0; index < tail.length; index += 1) {
|
||||
if (tail[index]?.kind !== "user_message") continue;
|
||||
flushRun(index);
|
||||
output.push(tail[index]!);
|
||||
runStart = index + 1;
|
||||
}
|
||||
flushRun(tail.length);
|
||||
|
||||
if (turnsByHostId.size === 0) {
|
||||
return {
|
||||
tail,
|
||||
turnsByHostId: EMPTY_TURNS,
|
||||
nestedItemIds: EMPTY_IDS,
|
||||
hostIdByFoldedMessageId: EMPTY_HOSTS,
|
||||
};
|
||||
}
|
||||
return {
|
||||
tail: output.length === tail.length ? tail : output,
|
||||
turnsByHostId,
|
||||
nestedItemIds,
|
||||
hostIdByFoldedMessageId,
|
||||
};
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Summary } from "lucide-react-native";
|
||||
import { ExpandableBadge } from "@/components/message";
|
||||
import { useOverviewSummaryLabel } from "@/tool-calls/detail-level/overview/label";
|
||||
import type { CollapsedTurn } from "./model";
|
||||
|
||||
/**
|
||||
* The row a collapsed turn leaves under its user message, styled like a tool-call group.
|
||||
* Pressing it opens the turn's rows beneath it.
|
||||
*/
|
||||
export const TurnSummaryLine = memo(function TurnSummaryLine({
|
||||
turn,
|
||||
onExpandedChange,
|
||||
}: {
|
||||
turn: CollapsedTurn;
|
||||
onExpandedChange: (hostId: string, expanded: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { messageCount, tools } = turn.summary;
|
||||
const messageParts = useMemo(
|
||||
() =>
|
||||
messageCount > 0
|
||||
? [
|
||||
t(`turnSummary.messages.${messageCount === 1 ? "one" : "other"}`, {
|
||||
count: messageCount,
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
[messageCount, t],
|
||||
);
|
||||
const label = useOverviewSummaryLabel(tools, messageParts);
|
||||
const toggle = useCallback(
|
||||
() => onExpandedChange(turn.hostId, !turn.expanded),
|
||||
[onExpandedChange, turn.expanded, turn.hostId],
|
||||
);
|
||||
|
||||
return (
|
||||
<ExpandableBadge
|
||||
testID="turn-summary"
|
||||
label={label}
|
||||
icon={Summary}
|
||||
isExpanded={turn.expanded}
|
||||
onToggle={toggle}
|
||||
borderlessWhenExpanded
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import type { Virtualizer } from "@tanstack/react-virtual";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getStreamItemMessageId } from "./presentation";
|
||||
import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle";
|
||||
import type { ScrollToMessageOccurrence } from "./strategy";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
|
||||
import { getStreamItemMessageId } from "./presentation";
|
||||
|
||||
// Callers reveal a message, not a row: an assistant message is several block rows and
|
||||
// the window has to open at the first of them.
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
type InlinePathTarget,
|
||||
} from "@/components/message";
|
||||
import { PlanCard } from "@/components/plan-card";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { PendingMessageSubmission } from "@/composer/submission/model";
|
||||
import type { TurnPresentation } from "@/timeline/turn-liveness";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
@@ -62,10 +62,8 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { ToolCallDetailsContent } from "@/components/tool-call-details";
|
||||
import { QuestionFormCard } from "@/components/question-form-card";
|
||||
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
|
||||
import { createStreamPresentation } from "./presentation";
|
||||
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
|
||||
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
|
||||
import { IndentRail } from "@/components/indent-rail";
|
||||
import { TurnSummaryLine } from "./turn-collapse/summary-line";
|
||||
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
|
||||
@@ -90,6 +88,7 @@ import {
|
||||
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
|
||||
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
|
||||
import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links";
|
||||
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
|
||||
import {
|
||||
createWorkspaceFileTabTarget,
|
||||
type WorkspaceFileLocation,
|
||||
@@ -150,16 +149,9 @@ function renderPendingPermissionsNode(input: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a row sits in an opened turn: its summary line's row, a row inside the turn, or the
|
||||
* turn's last row. The rail runs through the gaps between them so the turn reads as one block.
|
||||
*/
|
||||
type TurnIndent = "none" | "openedHost" | "nested" | "lastNested";
|
||||
|
||||
function renderStreamItemWithTurnFooter(input: {
|
||||
content: ReactNode;
|
||||
layoutItem: StreamLayoutItem;
|
||||
turnIndent: TurnIndent;
|
||||
strategy: TurnContentStrategy;
|
||||
supportsTimelineCursor: boolean;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
@@ -180,7 +172,7 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
/>
|
||||
) : null;
|
||||
const content = (
|
||||
<StreamItemWrapper gapBelow={input.layoutItem.gapBelow} turnIndent={input.turnIndent}>
|
||||
<StreamItemWrapper itemId={input.layoutItem.item.id} gapBelow={input.layoutItem.gapBelow}>
|
||||
{input.content}
|
||||
</StreamItemWrapper>
|
||||
);
|
||||
@@ -342,7 +334,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
|
||||
const chatDisplay = useSettings((settings) => settings.chatDisplay);
|
||||
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
|
||||
const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled);
|
||||
const contentMaxWidth = useSettings(resolveContentMaxWidth);
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
@@ -366,7 +358,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [expandedTurnHostIds, setExpandedTurnHostIds] = useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
const resolvedServerId = serverId ?? context.serverId ?? "";
|
||||
@@ -431,7 +422,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
setIsNearBottom(true);
|
||||
setExpandedInlineToolCallIds(new Set());
|
||||
setExpandedToolCallGroupIds(new Set());
|
||||
setExpandedTurnHostIds(new Set());
|
||||
}, [agentId]);
|
||||
|
||||
const isActive = useRetainedPanelActive();
|
||||
@@ -459,6 +449,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
view: "files",
|
||||
});
|
||||
});
|
||||
const openInlineOutsideDirectory = useOpenOutsideWorkspaceFolder(resolvedServerId);
|
||||
const inlinePathOpener = useMemo(
|
||||
() =>
|
||||
createInlinePathOpener({
|
||||
@@ -466,8 +457,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
client,
|
||||
openFile: openInlineFile,
|
||||
openDirectory: openInlineDirectory,
|
||||
openOutsideDirectory: openInlineOutsideDirectory,
|
||||
}),
|
||||
[workspaceRoot, client, openInlineFile, openInlineDirectory],
|
||||
[workspaceRoot, client, openInlineFile, openInlineDirectory, openInlineOutsideDirectory],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isActive) inlinePathOpener.cancel();
|
||||
@@ -523,8 +515,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
tail: effectiveStreamItems,
|
||||
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
|
||||
transform: transformTimelineItem,
|
||||
display: chatDisplay,
|
||||
expandedTurnHostIds,
|
||||
level: toolCallDetailLevel,
|
||||
isTurnActive,
|
||||
}),
|
||||
[
|
||||
@@ -532,8 +523,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
effectiveStreamItems,
|
||||
effectiveStreamHead,
|
||||
transformTimelineItem,
|
||||
chatDisplay,
|
||||
expandedTurnHostIds,
|
||||
toolCallDetailLevel,
|
||||
isTurnActive,
|
||||
],
|
||||
);
|
||||
@@ -664,43 +654,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[streamRenderStrategy],
|
||||
);
|
||||
|
||||
const setTurnExpanded = useCallback((hostId: string, expanded: boolean) => {
|
||||
// A turn opens below its summary line; keep that line under the reader's pointer.
|
||||
viewportRef.current?.holdReadingPosition();
|
||||
setExpandedTurnHostIds((previous) => {
|
||||
if (previous.has(hostId) === expanded) return previous;
|
||||
const next = new Set(previous);
|
||||
if (expanded) {
|
||||
next.add(hostId);
|
||||
} else {
|
||||
next.delete(hostId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
// Chat find reveals a match inside a collapsed turn by opening the turn first.
|
||||
const revealLoadedMessage = useStableEvent((messageId: string): boolean => {
|
||||
const hostId = presentation.turns.hostIdByFoldedMessageId.get(messageId);
|
||||
if (hostId && !expandedTurnHostIds.has(hostId)) {
|
||||
setTurnExpanded(hostId, true);
|
||||
return true;
|
||||
}
|
||||
return revealLoadedHistory(messageId);
|
||||
});
|
||||
// Read through stable events so a collapse rerun does not change the renderer identity;
|
||||
// rows whose turn state changed are revised through `historyRowRevision`.
|
||||
const getCollapsedTurn = useStableEvent((hostId: string) =>
|
||||
presentation.turns.turnsByHostId.get(hostId),
|
||||
);
|
||||
const getTurnIndent = useStableEvent((layoutItem: StreamLayoutItem): TurnIndent => {
|
||||
const { nestedItemIds } = presentation.turns;
|
||||
if (nestedItemIds.has(layoutItem.item.id)) {
|
||||
const below = layoutItem.belowItem;
|
||||
return below && nestedItemIds.has(below.id) ? "nested" : "lastNested";
|
||||
}
|
||||
return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none";
|
||||
});
|
||||
|
||||
const setToolCallGroupExpanded = useCallback((groupId: string, expanded: boolean) => {
|
||||
setExpandedToolCallGroupIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
@@ -715,8 +668,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const renderUserMessageItem = useCallback(
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
|
||||
const collapsedTurn = getCollapsedTurn(item.id);
|
||||
const message = (
|
||||
return (
|
||||
<UserMessage
|
||||
serverId={resolvedServerId}
|
||||
agentId={agentId}
|
||||
@@ -735,25 +687,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}
|
||||
/>
|
||||
);
|
||||
if (!collapsedTurn) {
|
||||
return message;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{message}
|
||||
<TurnSummaryLine turn={collapsedTurn} onExpandedChange={setTurnExpanded} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
[
|
||||
context.capabilities,
|
||||
agentId,
|
||||
client,
|
||||
getCollapsedTurn,
|
||||
pendingClientMessageIds,
|
||||
resolvedServerId,
|
||||
setTurnExpanded,
|
||||
],
|
||||
[context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId],
|
||||
);
|
||||
|
||||
const renderAssistantMessageItem = useCallback(
|
||||
@@ -961,14 +896,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return renderStreamItemWithTurnFooter({
|
||||
content,
|
||||
layoutItem,
|
||||
turnIndent: getTurnIndent(layoutItem),
|
||||
strategy: streamRenderStrategy,
|
||||
supportsTimelineCursor: supportsAgentForkContextCursor,
|
||||
onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn,
|
||||
});
|
||||
},
|
||||
[
|
||||
getTurnIndent,
|
||||
handleForkAssistantTurn,
|
||||
readOnly,
|
||||
renderStreamItemContent,
|
||||
@@ -1130,25 +1063,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const streamScrollEnabled =
|
||||
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
|
||||
expandedInlineToolCallIds.size === 0;
|
||||
const { nestedItemIds } = presentation.turns;
|
||||
const historyRowRevision = useMemo(
|
||||
() => ({
|
||||
contentById: presentation.historyGroupUpdatesByHostId,
|
||||
displayStateById: {
|
||||
has: (id: string) =>
|
||||
expandedToolCallGroupIds.has(id) ||
|
||||
expandedTurnHostIds.has(id) ||
|
||||
nestedItemIds.has(id),
|
||||
},
|
||||
displayStateById: expandedToolCallGroupIds,
|
||||
globalDisplayState: isMobile,
|
||||
}),
|
||||
[
|
||||
expandedToolCallGroupIds,
|
||||
expandedTurnHostIds,
|
||||
isMobile,
|
||||
nestedItemIds,
|
||||
presentation.historyGroupUpdatesByHostId,
|
||||
],
|
||||
[expandedToolCallGroupIds, isMobile, presentation.historyGroupUpdatesByHostId],
|
||||
);
|
||||
|
||||
const findItems = useMemo(
|
||||
@@ -1162,7 +1083,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
epoch={timelineEpoch}
|
||||
items={findItems}
|
||||
viewportRef={viewportRef}
|
||||
revealLoadedMessage={revealLoadedMessage}
|
||||
revealLoadedMessage={revealLoadedHistory}
|
||||
visibleMessageIds={mountedMessageIds}
|
||||
>
|
||||
<ToolCallSheetProvider>
|
||||
@@ -1845,41 +1766,15 @@ const permissionStyles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
interface StreamItemWrapperProps {
|
||||
itemId: string;
|
||||
gapBelow: number;
|
||||
turnIndent: TurnIndent;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function StreamItemWrapper({ gapBelow, turnIndent, children }: StreamItemWrapperProps) {
|
||||
function StreamItemWrapper({ gapBelow, children }: StreamItemWrapperProps) {
|
||||
const wrapperStyle = useMemo(
|
||||
() => [
|
||||
stylesheet.streamItemWrapper,
|
||||
{ marginBottom: turnIndent === "nested" || turnIndent === "openedHost" ? 0 : gapBelow },
|
||||
],
|
||||
[gapBelow, turnIndent],
|
||||
() => [stylesheet.streamItemWrapper, { marginBottom: gapBelow }],
|
||||
[gapBelow],
|
||||
);
|
||||
const gapStyle = useMemo(() => ({ paddingBottom: gapBelow }), [gapBelow]);
|
||||
switch (turnIndent) {
|
||||
case "none":
|
||||
return <View style={wrapperStyle}>{children}</View>;
|
||||
case "openedHost":
|
||||
return (
|
||||
<View style={wrapperStyle}>
|
||||
{children}
|
||||
<IndentRail style={gapStyle} />
|
||||
</View>
|
||||
);
|
||||
case "nested":
|
||||
return (
|
||||
<View style={wrapperStyle}>
|
||||
<IndentRail style={gapStyle}>{children}</IndentRail>
|
||||
</View>
|
||||
);
|
||||
case "lastNested":
|
||||
return (
|
||||
<View style={wrapperStyle}>
|
||||
<IndentRail>{children}</IndentRail>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return <View style={wrapperStyle}>{children}</View>;
|
||||
}
|
||||
|
||||
@@ -72,7 +72,6 @@ import {
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||
import { useSettingsRouteEntry } from "@/navigation/settings-selection";
|
||||
import { ThemedStack } from "@/navigation/themed-stack";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { AgentNavigationListener } from "@/desktop/agent-navigation";
|
||||
@@ -760,7 +759,6 @@ function OpenProjectListener() {
|
||||
}
|
||||
|
||||
function AppWithSidebar({ children }: { children: ReactNode }) {
|
||||
useSettingsRouteEntry();
|
||||
const pathname = usePathname();
|
||||
const hosts = useHosts();
|
||||
const storeReady = useStoreReady();
|
||||
|
||||
@@ -71,6 +71,9 @@ it("ignores an older probe after a newer file link opens", async () => {
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred");
|
||||
await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred");
|
||||
@@ -95,6 +98,9 @@ it("ignores a pending probe when its view is left", async () => {
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred");
|
||||
opener.cancel();
|
||||
@@ -102,3 +108,25 @@ it("ignores a pending probe when its view is left", async () => {
|
||||
await pending;
|
||||
expect(opened).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends folders outside the workspace away from the workspace Files panel", async () => {
|
||||
const { createInlinePathOpener } = await import("./path-open");
|
||||
const workspaceFolders: string[] = [];
|
||||
const outsideFolders: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/Users/me/repo",
|
||||
client: null,
|
||||
openFile: () => {},
|
||||
openDirectory: (directory) => {
|
||||
workspaceFolders.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
outsideFolders.push(directory);
|
||||
},
|
||||
});
|
||||
for (const raw of ["~/Downloads/contracts/", "/tmp/reports/", "/Users/me/repo/docs/"]) {
|
||||
await opener.open({ raw, path: raw }, "preferred");
|
||||
}
|
||||
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
|
||||
expect(workspaceFolders).toEqual(["docs"]);
|
||||
});
|
||||
|
||||
@@ -14,12 +14,23 @@ interface PathOpenInput {
|
||||
client: { listDirectory(root: string, path: string): Promise<unknown> } | null;
|
||||
}
|
||||
|
||||
type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string };
|
||||
type PathOpenTarget =
|
||||
| { kind: "file" }
|
||||
| { kind: "directory"; directoryPath: string }
|
||||
| { kind: "outsideDirectory"; directoryPath: string };
|
||||
|
||||
// Workspace paths arrive relative to the root; anything still absolute or
|
||||
// home-relative lives outside it, where the Files panel cannot list it.
|
||||
function isOutsideWorkspacePath(pathValue: string): boolean {
|
||||
return isAbsolutePath(pathValue) || pathValue === "~" || pathValue.startsWith("~/");
|
||||
}
|
||||
|
||||
/** Resolve workspace folders from filesystem facts, including names that contain dots. */
|
||||
export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathOpenTarget> {
|
||||
if (!input.file) {
|
||||
return { kind: "directory", directoryPath: input.directory };
|
||||
return isOutsideWorkspacePath(input.directory)
|
||||
? { kind: "outsideDirectory", directoryPath: input.directory }
|
||||
: { kind: "directory", directoryPath: input.directory };
|
||||
}
|
||||
if (
|
||||
input.lineStart !== undefined ||
|
||||
@@ -42,6 +53,7 @@ export function createInlinePathOpener(input: {
|
||||
client: PathOpenInput["client"];
|
||||
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
|
||||
openDirectory: (path: string) => void;
|
||||
openOutsideDirectory: (path: string) => void;
|
||||
}) {
|
||||
let generation = 0;
|
||||
return {
|
||||
@@ -58,6 +70,8 @@ export function createInlinePathOpener(input: {
|
||||
if (ownGeneration !== generation) return;
|
||||
if (destination.kind === "directory") {
|
||||
input.openDirectory(destination.directoryPath);
|
||||
} else if (destination.kind === "outsideDirectory") {
|
||||
input.openOutsideDirectory(destination.directoryPath);
|
||||
} else if (normalized.file) {
|
||||
const location = normalizeWorkspaceFileLocation({
|
||||
path: normalized.file,
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import type { ImportSessionListComponent } from "./import-session-list.types";
|
||||
|
||||
// Native keeps the sheet-owned ScrollView until its gestures and keyboard are verified.
|
||||
export const ImportSessionList: ImportSessionListComponent | null = null;
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
|
||||
export interface ImportSessionListProps<Item> {
|
||||
data: ReadonlyArray<Item>;
|
||||
keyExtractor: (item: Item) => string;
|
||||
renderItem: (item: Item) => ReactNode;
|
||||
header: ReactElement | null;
|
||||
footer: ReactElement | null;
|
||||
}
|
||||
|
||||
export type ImportSessionListComponent = <Item>(
|
||||
props: ImportSessionListProps<Item>,
|
||||
) => ReactElement;
|
||||
@@ -1,141 +0,0 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type FocusEvent,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import { View, type LayoutChangeEvent } from "react-native";
|
||||
import { defaultRangeExtractor, useVirtualizer, type Range } from "@tanstack/react-virtual";
|
||||
import type { ImportSessionListProps } from "./import-session-list.types";
|
||||
|
||||
const SCROLLER_STYLE: CSSProperties = {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
overscrollBehavior: "contain",
|
||||
};
|
||||
const CONTENT_STYLE: CSSProperties = { width: "100%", position: "relative" };
|
||||
|
||||
export function ImportSessionList<Item>({
|
||||
data,
|
||||
keyExtractor,
|
||||
renderItem,
|
||||
header,
|
||||
footer,
|
||||
}: ImportSessionListProps<Item>) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [headerHeight, setHeaderHeight] = useState(0);
|
||||
const [focusedKey, setFocusedKey] = useState<string | null>(null);
|
||||
const indexByKey = useMemo(
|
||||
() => new Map(data.map((item, index) => [keyExtractor(item), index])),
|
||||
[data, keyExtractor],
|
||||
);
|
||||
const focusedIndex = focusedKey === null ? undefined : indexByKey.get(focusedKey);
|
||||
const rangeExtractor = useCallback(
|
||||
(range: Range) => {
|
||||
const visible = defaultRangeExtractor(range);
|
||||
if (focusedIndex === undefined) return visible;
|
||||
// Keep focus and its Tab neighbours mounted when PageDown/End moves the window.
|
||||
const focused = [focusedIndex - 1, focusedIndex, focusedIndex + 1].filter(
|
||||
(index) => index >= 0 && index < data.length,
|
||||
);
|
||||
return [...new Set([...visible, ...focused])].sort((left, right) => left - right);
|
||||
},
|
||||
[data.length, focusedIndex],
|
||||
);
|
||||
const handleFocus = useCallback((event: FocusEvent<HTMLDivElement>) => {
|
||||
const row = event.target.closest<HTMLElement>("[data-session-key]");
|
||||
setFocusedKey(row?.dataset.sessionKey ?? null);
|
||||
}, []);
|
||||
const handleBlur = useCallback((event: FocusEvent<HTMLDivElement>) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) setFocusedKey(null);
|
||||
}, []);
|
||||
const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setHeaderHeight(event.nativeEvent.layout.height);
|
||||
}, []);
|
||||
const getScrollElement = useCallback(() => scrollRef.current, []);
|
||||
const getItemKey = useCallback(
|
||||
(index: number) => keyExtractor(data[index]!),
|
||||
[data, keyExtractor],
|
||||
);
|
||||
const estimateSize = useCallback(() => 88, []);
|
||||
const virtualizer = useVirtualizer({
|
||||
count: data.length,
|
||||
getScrollElement,
|
||||
getItemKey,
|
||||
estimateSize,
|
||||
scrollMargin: headerHeight,
|
||||
overscan: 5,
|
||||
rangeExtractor,
|
||||
});
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
!(target instanceof HTMLElement) ||
|
||||
!event.currentTarget.contains(target) ||
|
||||
target.isContentEditable ||
|
||||
target.closest("input, textarea, select")
|
||||
)
|
||||
return;
|
||||
if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
// Reconcile the landing position as variable-height rows are measured.
|
||||
virtualizer.scrollToIndex(data.length - 1, { align: "end" });
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
virtualizer.scrollToOffset(0);
|
||||
} else if (event.key === "PageDown" || event.key === "PageUp") {
|
||||
event.preventDefault();
|
||||
// Native page-scroll animations can keep moving after Home or End.
|
||||
const direction = event.key === "PageDown" ? 1 : -1;
|
||||
virtualizer.scrollBy(direction * event.currentTarget.clientHeight);
|
||||
}
|
||||
},
|
||||
[data.length, virtualizer],
|
||||
);
|
||||
const virtualRows = virtualizer.getVirtualItems();
|
||||
const totalSize = virtualizer.getTotalSize();
|
||||
const contentStyle = useMemo(() => ({ ...CONTENT_STYLE, height: totalSize }), [totalSize]);
|
||||
const rowStyle = useCallback(
|
||||
(offset: number): CSSProperties => ({
|
||||
...CONTENT_STYLE,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
transform: `translateY(${offset}px)`,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={SCROLLER_STYLE}
|
||||
data-testid="import-session-list"
|
||||
onFocusCapture={handleFocus}
|
||||
onBlurCapture={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<View onLayout={handleHeaderLayout}>{header}</View>
|
||||
<div style={contentStyle}>
|
||||
{virtualRows.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
data-index={row.index}
|
||||
data-session-key={row.key}
|
||||
ref={virtualizer.measureElement}
|
||||
style={rowStyle(row.start - headerHeight)}
|
||||
>
|
||||
{renderItem(data[row.index]!)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>{footer}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
|
||||
import { keepPreviousData, useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -11,7 +11,6 @@ import { ChevronDown, Inbox, Layers, RotateCw } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ImportSessionList } from "@/components/import-session-list";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { useProviderIcon, useProviderIcons } from "@/components/provider-icons";
|
||||
@@ -372,33 +371,7 @@ function ImportSessionActivityTime({ date }: { date: string }) {
|
||||
return <Text style={styles.rowMeta}>{label}</Text>;
|
||||
}
|
||||
|
||||
function getSessionRowKey(entry: FetchRecentProviderSessionEntry): string {
|
||||
return `${entry.providerId}:${entry.providerHandleId}`;
|
||||
}
|
||||
|
||||
function hasListHeader(
|
||||
input: SheetStatusMessagesProps & {
|
||||
showFilter: boolean;
|
||||
hasProviderErrors: boolean;
|
||||
},
|
||||
): boolean {
|
||||
return (
|
||||
input.showFilter ||
|
||||
!input.isClientReady ||
|
||||
input.isSnapshotUnsupported ||
|
||||
input.hasNoImportableProviders ||
|
||||
(input.isLoadingSessions && !input.hasRows) ||
|
||||
input.importErrored ||
|
||||
input.hasProviderErrors
|
||||
);
|
||||
}
|
||||
|
||||
function SessionRows({
|
||||
visible,
|
||||
onClose,
|
||||
header,
|
||||
listHeader,
|
||||
listFooter,
|
||||
serverId,
|
||||
entries,
|
||||
disabled,
|
||||
@@ -406,11 +379,6 @@ function SessionRows({
|
||||
resolveFolder,
|
||||
onImportSession,
|
||||
}: {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
header: SheetHeader;
|
||||
listHeader: ReactNode;
|
||||
listFooter: ReactNode;
|
||||
serverId: string | null;
|
||||
entries: ReadonlyArray<FetchRecentProviderSessionEntry>;
|
||||
disabled: boolean;
|
||||
@@ -418,69 +386,20 @@ function SessionRows({
|
||||
resolveFolder: (entry: FetchRecentProviderSessionEntry) => string | null;
|
||||
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
|
||||
}) {
|
||||
// Keep short results content-sized; only long lists need a bounded viewport.
|
||||
const virtualized = ImportSessionList !== null && entries.length > 8;
|
||||
const renderSessionRow = useCallback(
|
||||
(entry: FetchRecentProviderSessionEntry) => (
|
||||
<ImportSessionSheetRow
|
||||
key={getSessionRowKey(entry)}
|
||||
serverId={serverId}
|
||||
entry={entry}
|
||||
disabled={disabled}
|
||||
importing={importingSessionKey === getSessionRowKey(entry)}
|
||||
folder={resolveFolder(entry)}
|
||||
onImportSession={onImportSession}
|
||||
/>
|
||||
),
|
||||
[serverId, disabled, importingSessionKey, resolveFolder, onImportSession],
|
||||
);
|
||||
const renderItem = useCallback(
|
||||
(item: FetchRecentProviderSessionEntry) => (
|
||||
<View style={styles.listRow}>{renderSessionRow(item)}</View>
|
||||
),
|
||||
[renderSessionRow],
|
||||
);
|
||||
const virtualHeader = useMemo(
|
||||
() => (listHeader ? <View style={styles.listHeader}>{listHeader}</View> : null),
|
||||
[listHeader],
|
||||
);
|
||||
const virtualFooter = useMemo(
|
||||
() => <View style={styles.listFooter}>{listFooter}</View>,
|
||||
[listFooter],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
header={header}
|
||||
testID="import-session-sheet"
|
||||
desktopMaxWidth={560}
|
||||
desktopHeight={virtualized ? "85%" : undefined}
|
||||
snapPoints={IMPORT_SHEET_SNAP_POINTS}
|
||||
scrollable={!virtualized}
|
||||
contentStyle={virtualized ? styles.listBody : undefined}
|
||||
>
|
||||
{ImportSessionList && virtualized ? (
|
||||
<View style={styles.list}>
|
||||
<ImportSessionList
|
||||
data={entries}
|
||||
keyExtractor={getSessionRowKey}
|
||||
renderItem={renderItem}
|
||||
header={virtualHeader}
|
||||
footer={virtualFooter}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{listHeader}
|
||||
{entries.length > 0 ? (
|
||||
<View style={styles.smallList}>{entries.map(renderSessionRow)}</View>
|
||||
) : null}
|
||||
{listFooter}
|
||||
</>
|
||||
)}
|
||||
</AdaptiveModalSheet>
|
||||
<View style={styles.list}>
|
||||
{entries.map((entry) => (
|
||||
<ImportSessionSheetRow
|
||||
key={`${entry.providerId}:${entry.providerHandleId}`}
|
||||
serverId={serverId}
|
||||
entry={entry}
|
||||
disabled={disabled}
|
||||
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
|
||||
folder={resolveFolder(entry)}
|
||||
onImportSession={onImportSession}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -807,124 +726,85 @@ export function ImportSessionSheet({
|
||||
});
|
||||
const showFilter = filterProviders.length > 1;
|
||||
const showLoadMore = hasMoreSessions(queries, pageLimit);
|
||||
const showListHeader = hasListHeader({
|
||||
showFilter,
|
||||
isClientReady: Boolean(client),
|
||||
isSnapshotUnsupported,
|
||||
hasNoImportableProviders,
|
||||
isLoadingSessions,
|
||||
hasRows: visibleEntries.length > 0,
|
||||
importErrored: importMutation.isError,
|
||||
hasProviderErrors: providerErrorRows.length > 0,
|
||||
});
|
||||
const listHeader = useMemo(
|
||||
() =>
|
||||
showListHeader ? (
|
||||
<View style={styles.listHeaderContent}>
|
||||
{showFilter ? (
|
||||
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
|
||||
<Pressable
|
||||
onPress={handleFilterOpen}
|
||||
style={filterTriggerStyle}
|
||||
testID="import-session-filter-trigger"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Filter: ${selectedProviderLabel}`}
|
||||
>
|
||||
{selectedProvider === ALL_FILTER_VALUE ? (
|
||||
<Layers size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<SelectedProviderIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
<Text style={styles.filterTriggerText} numberOfLines={1}>
|
||||
{selectedProviderLabel}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={filterComboboxOptions}
|
||||
value={selectedProvider}
|
||||
onSelect={handleFilterSelect}
|
||||
renderOption={renderFilterOption}
|
||||
searchable={false}
|
||||
title="Filter by provider"
|
||||
open={isFilterOpen}
|
||||
onOpenChange={setIsFilterOpen}
|
||||
anchorRef={filterAnchorRef}
|
||||
desktopPlacement="bottom-start"
|
||||
desktopPreventInitialFlash
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
<SheetStatusMessages
|
||||
isClientReady={Boolean(client)}
|
||||
isSnapshotUnsupported={isSnapshotUnsupported}
|
||||
hasNoImportableProviders={hasNoImportableProviders}
|
||||
isLoadingSessions={isLoadingSessions}
|
||||
hasRows={visibleEntries.length > 0}
|
||||
importErrored={importMutation.isError}
|
||||
/>
|
||||
{providerErrorRows.length > 0 ? (
|
||||
<ProviderErrorBanner rows={providerErrorRows} onRetry={handleRetryProvider} />
|
||||
) : null}
|
||||
</View>
|
||||
) : null,
|
||||
[
|
||||
showListHeader,
|
||||
showFilter,
|
||||
filterTriggerStyle,
|
||||
handleFilterOpen,
|
||||
selectedProvider,
|
||||
theme.colors.foregroundMuted,
|
||||
SelectedProviderIcon,
|
||||
selectedProviderLabel,
|
||||
filterComboboxOptions,
|
||||
handleFilterSelect,
|
||||
renderFilterOption,
|
||||
isFilterOpen,
|
||||
client,
|
||||
isSnapshotUnsupported,
|
||||
hasNoImportableProviders,
|
||||
isLoadingSessions,
|
||||
visibleEntries.length,
|
||||
importMutation.isError,
|
||||
providerErrorRows,
|
||||
handleRetryProvider,
|
||||
],
|
||||
);
|
||||
const listFooter = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{showLoadMore ? (
|
||||
<View style={styles.footer}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onPress={handleLoadMore}
|
||||
disabled={isRefreshing}
|
||||
testID="import-session-load-more"
|
||||
>
|
||||
{t("importSession.actions.loadMore")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
|
||||
</>
|
||||
),
|
||||
[showLoadMore, handleLoadMore, isRefreshing, t, showEmptyState, emptyStateTitle],
|
||||
);
|
||||
|
||||
return (
|
||||
<SessionRows
|
||||
<AdaptiveModalSheet
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
header={header}
|
||||
listHeader={listHeader}
|
||||
listFooter={listFooter}
|
||||
serverId={serverId}
|
||||
entries={visibleEntries}
|
||||
disabled={importMutation.isPending}
|
||||
importingSessionKey={importingSessionKey}
|
||||
resolveFolder={resolveFolder}
|
||||
onImportSession={handleImportSession}
|
||||
/>
|
||||
testID="import-session-sheet"
|
||||
desktopMaxWidth={560}
|
||||
snapPoints={IMPORT_SHEET_SNAP_POINTS}
|
||||
>
|
||||
{showFilter ? (
|
||||
<View ref={filterAnchorRef} collapsable={false} style={styles.filterTriggerWrap}>
|
||||
<Pressable
|
||||
onPress={handleFilterOpen}
|
||||
style={filterTriggerStyle}
|
||||
testID="import-session-filter-trigger"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Filter: ${selectedProviderLabel}`}
|
||||
>
|
||||
{selectedProvider === ALL_FILTER_VALUE ? (
|
||||
<Layers size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<SelectedProviderIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
<Text style={styles.filterTriggerText} numberOfLines={1}>
|
||||
{selectedProviderLabel}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Combobox
|
||||
options={filterComboboxOptions}
|
||||
value={selectedProvider}
|
||||
onSelect={handleFilterSelect}
|
||||
renderOption={renderFilterOption}
|
||||
searchable={false}
|
||||
title="Filter by provider"
|
||||
open={isFilterOpen}
|
||||
onOpenChange={setIsFilterOpen}
|
||||
anchorRef={filterAnchorRef}
|
||||
desktopPlacement="bottom-start"
|
||||
desktopPreventInitialFlash
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
<SheetStatusMessages
|
||||
isClientReady={Boolean(client)}
|
||||
isSnapshotUnsupported={isSnapshotUnsupported}
|
||||
hasNoImportableProviders={hasNoImportableProviders}
|
||||
isLoadingSessions={isLoadingSessions}
|
||||
hasRows={visibleEntries.length > 0}
|
||||
importErrored={importMutation.isError}
|
||||
/>
|
||||
{providerErrorRows.length > 0 ? (
|
||||
<ProviderErrorBanner rows={providerErrorRows} onRetry={handleRetryProvider} />
|
||||
) : null}
|
||||
{visibleEntries.length > 0 ? (
|
||||
<SessionRows
|
||||
serverId={serverId}
|
||||
entries={visibleEntries}
|
||||
disabled={importMutation.isPending}
|
||||
importingSessionKey={importingSessionKey}
|
||||
resolveFolder={resolveFolder}
|
||||
onImportSession={handleImportSession}
|
||||
/>
|
||||
) : null}
|
||||
{showLoadMore ? (
|
||||
<View style={styles.footer}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onPress={handleLoadMore}
|
||||
disabled={isRefreshing}
|
||||
testID="import-session-load-more"
|
||||
>
|
||||
{t("importSession.actions.loadMore")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -973,31 +853,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
listBody: {
|
||||
gap: 0,
|
||||
},
|
||||
list: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
marginHorizontal: -theme.spacing[2],
|
||||
},
|
||||
listHeader: {
|
||||
marginHorizontal: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
listHeaderContent: {
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
smallList: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
listFooter: {
|
||||
marginHorizontal: theme.spacing[2],
|
||||
},
|
||||
listRow: {
|
||||
marginHorizontal: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
footer: {
|
||||
paddingTop: theme.spacing[2],
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
/**
|
||||
* Collapsed chat rows badge their summary with an icon whose center sits 7px into the content
|
||||
* column (`ExpandableBadge` bleeds 13px left, then 1px border, 8px padding, 22px icon). The rail
|
||||
* hangs from that center so opened rows read as children of the line that opened them.
|
||||
*/
|
||||
const RAIL_LEFT = 7;
|
||||
const RAIL_WIDTH = 1;
|
||||
export const INDENT_RAIL_CONTENT_OFFSET = 21;
|
||||
|
||||
/** Indents opened chat rows under the summary that collapsed them, behind a vertical line. */
|
||||
export function IndentRail({
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
return <View style={[styles.rail, style]}>{children}</View>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
rail: {
|
||||
marginLeft: RAIL_LEFT,
|
||||
paddingLeft: INDENT_RAIL_CONTENT_OFFSET - RAIL_LEFT - RAIL_WIDTH,
|
||||
borderLeftWidth: RAIL_WIDTH,
|
||||
borderLeftColor: theme.colors.border,
|
||||
},
|
||||
}));
|
||||
@@ -719,11 +719,8 @@ function DesktopSidebar({
|
||||
[active, resizeAnimatedStyle],
|
||||
);
|
||||
const desktopSidebarBorderStyle = useMemo(
|
||||
() => [
|
||||
styles.desktopSidebarBorder,
|
||||
{ flex: 1, paddingTop: insetsTop, paddingBottom: insets.bottom },
|
||||
],
|
||||
[insetsTop, insets.bottom],
|
||||
() => [styles.desktopSidebarBorder, { flex: 1, paddingTop: insetsTop }],
|
||||
[insetsTop],
|
||||
);
|
||||
const sidebarHeaderGroupStyle = useMemo(
|
||||
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
|
||||
|
||||
@@ -249,35 +249,6 @@ describe("bare URL sentence punctuation", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["http://127.0.0.1:8059/?token=t-1", ""],
|
||||
["http://example.com:8080/a", "."],
|
||||
["http://localhost:8059/?token=t-1", "."],
|
||||
["http://localhost:8059/?token=t-1", ")"],
|
||||
["http://user@127.0.0.1:8059/?token=t-1", '"'],
|
||||
["http://user:pass@127.0.0.1:8059/?token=t-1", ""],
|
||||
["http://localhost", ":"],
|
||||
["http://[::1]:8059/?token=t-1", "'"],
|
||||
["https://github.com/o/r/compare/main...user:repo:fix/branch", ""],
|
||||
["https://example.com/a:b/c", ":"],
|
||||
])("links %s whole and leaves %j outside", async (url, punctuation) => {
|
||||
const prepared = prepareMarkdownSource(`See ${url}${punctuation} now`);
|
||||
const tree = recognizeTextLinks(
|
||||
await parseMarkdown(prepared.render(), { latexMath: false }),
|
||||
prepared.linkRecognition.text,
|
||||
prepared.linkRecognition.inlineCode,
|
||||
);
|
||||
expect(tree.children?.[0]?.children).toEqual([
|
||||
{ type: "Text", content: "See " },
|
||||
{
|
||||
type: "Link",
|
||||
attributes: expect.objectContaining({ url }),
|
||||
children: [{ type: "Text", content: url }],
|
||||
},
|
||||
{ type: "Text", content: `${punctuation} now` },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["[Authored](https://example.com/a.)", "<https://example.com/a.>"])(
|
||||
"preserves punctuation in explicit destinations: %s",
|
||||
async (source) => {
|
||||
@@ -804,18 +775,3 @@ it("makes the web Markdown container selectable in Safari as well as unprefixed
|
||||
WebkitUserSelect: "text",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"scales inline code from theme tokens with its surrounding block (compact: %s)",
|
||||
(compact) => {
|
||||
const theme = {
|
||||
...darkTheme,
|
||||
fontSize: { ...darkTheme.fontSize, code: 13, content: 17, base: 16 },
|
||||
};
|
||||
const { markdownStyle } = createMarkdownStyles(theme, { compact, blockGap: 16 });
|
||||
const bodySize = compact ? theme.fontSize.base : theme.fontSize.content;
|
||||
expect(markdownStyle.code).not.toHaveProperty("fontSize");
|
||||
expect(markdownStyle.code).toHaveProperty("fontScale", theme.fontSize.code / bodySize);
|
||||
expect(markdownStyle.codeBlock?.fontSize).toBe(theme.fontSize.code);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -23,29 +23,14 @@ const ThemedMarkdown = withUnistyles(EnrichedMarkdownText);
|
||||
const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: true };
|
||||
|
||||
// Reading the breakpoint here makes Unistyles restyle the text when the layout crosses it.
|
||||
const markdownStyle =
|
||||
(compact: boolean, muted = false) =>
|
||||
(theme: Theme, rt: { breakpoint?: string }) =>
|
||||
createMarkdownStyles(theme, {
|
||||
compact,
|
||||
muted,
|
||||
blockGap: resolveDensity(rt, density.blockGap),
|
||||
});
|
||||
const markdownStyle = (compact: boolean) => (theme: Theme, rt: { breakpoint?: string }) =>
|
||||
createMarkdownStyles(theme, { compact, blockGap: resolveDensity(rt, density.blockGap) });
|
||||
const regularStyle = markdownStyle(false);
|
||||
const compactStyle = markdownStyle(true);
|
||||
const compactMutedStyle = markdownStyle(true, true);
|
||||
|
||||
function pickMarkdownStyle(compact: boolean, muted: boolean) {
|
||||
if (!compact) return regularStyle;
|
||||
return muted ? compactMutedStyle : compactStyle;
|
||||
}
|
||||
|
||||
export interface MarkdownRendererProps extends MarkdownImageContext {
|
||||
text: string;
|
||||
compact?: boolean;
|
||||
/** Muted text colour. Compact surfaces only. */
|
||||
muted?: boolean;
|
||||
selectable?: boolean;
|
||||
streaming?: boolean;
|
||||
onLinkPress?: (url: string) => unknown;
|
||||
}
|
||||
@@ -71,8 +56,6 @@ export const MarkdownRenderer = memo(function MarkdownRenderer(props: MarkdownRe
|
||||
const MarkdownPart = memo(function MarkdownPart({
|
||||
text,
|
||||
compact = false,
|
||||
muted = false,
|
||||
selectable = true,
|
||||
streaming = false,
|
||||
onLinkPress,
|
||||
...imageContext
|
||||
@@ -115,9 +98,9 @@ const MarkdownPart = memo(function MarkdownPart({
|
||||
<ThemedMarkdown
|
||||
key={`${segment.kind}:${segment.id}`}
|
||||
markdown={segment.source}
|
||||
uniProps={pickMarkdownStyle(compact, muted)}
|
||||
uniProps={compact ? compactStyle : regularStyle}
|
||||
flavor="github"
|
||||
selectable={selectable}
|
||||
selectable
|
||||
md4cFlags={MD4C_FLAGS}
|
||||
linkRecognition={linkRecognition}
|
||||
enableTaskListItemToggle={false}
|
||||
|
||||
@@ -27,17 +27,11 @@ const PROSE_PATH = String.raw`(?:${PATH_PREFIX}${HAS_PATH_LETTER}${SEPARATED_PAT
|
||||
function createFilePattern(path: string): RegExp {
|
||||
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
|
||||
return new RegExp(
|
||||
String.raw`(?:${BARE_URL}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
|
||||
String.raw`(?:https?://[^\s<>]+|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
|
||||
"g",
|
||||
);
|
||||
}
|
||||
|
||||
// Matches bare URLs that MD4C does not autolink, like single-label or bracketed hosts.
|
||||
// Quotes and unbalanced closing parentheses end the URL. Trailing sentence punctuation stays outside.
|
||||
const URL_CHARACTER = String.raw`[^\s<>()"'\x60]`;
|
||||
const URL_GROUP = String.raw`\(${URL_CHARACTER}*\)`;
|
||||
const BARE_URL = String.raw`https?://(?:${URL_CHARACTER}|${URL_GROUP})*(?:[^\s<>()"'\x60.,;:!?]|${URL_GROUP})`;
|
||||
|
||||
const FILE_PATTERN = createFilePattern(PROSE_PATH);
|
||||
function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
|
||||
// The engine accepts regexes on both platforms. Classify complete code spans
|
||||
|
||||
@@ -7,11 +7,7 @@ const WEB_CONTAINER = { userSelect: "text", WebkitUserSelect: "text" } as const;
|
||||
|
||||
export function createMarkdownStyles(
|
||||
theme: Theme,
|
||||
{
|
||||
compact = false,
|
||||
muted = false,
|
||||
blockGap,
|
||||
}: { compact?: boolean; muted?: boolean; blockGap: number },
|
||||
{ compact = false, blockGap }: { compact?: boolean; blockGap: number },
|
||||
): { markdownStyle: MarkdownStyle; selectionColor: string; containerStyle?: typeof WEB_CONTAINER } {
|
||||
const fontSize = compact ? theme.fontSize.base : theme.fontSize.content;
|
||||
// Every block is followed by the same gap; the library drops it after the last block.
|
||||
@@ -20,7 +16,7 @@ export function createMarkdownStyles(
|
||||
fontSize,
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
lineHeight: Math.round(fontSize * 1.4),
|
||||
color: muted ? theme.colors.foregroundMuted : theme.colors.foreground,
|
||||
color: theme.colors.foreground,
|
||||
...block,
|
||||
};
|
||||
const heading = (scale: number) => ({
|
||||
@@ -72,7 +68,7 @@ export function createMarkdownStyles(
|
||||
},
|
||||
code: {
|
||||
fontFamily: theme.fontFamily.mono === "ui-monospace" && !isWeb ? "" : theme.fontFamily.mono,
|
||||
fontScale: theme.fontSize.code / fontSize,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderColor: "transparent",
|
||||
|
||||
@@ -971,10 +971,6 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
// Details that do not sit in a card leave the header as a lone highlighted pill.
|
||||
pressableExpandedDetached: {
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
detailWrapperBorderless: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
@@ -2052,10 +2048,7 @@ export const ExpandableBadge = memo(function ExpandableBadge({
|
||||
expandableBadgeStylesheet.pressable,
|
||||
isPressed && isInteractive ? expandableBadgeStylesheet.pressablePressed : null,
|
||||
isExpanded && expandableBadgeStylesheet.pressableExpanded,
|
||||
isExpanded &&
|
||||
(borderlessWhenExpanded
|
||||
? expandableBadgeStylesheet.pressableExpandedDetached
|
||||
: expandableBadgeStylesheet.pressableExpandedAttached),
|
||||
isExpanded && !borderlessWhenExpanded && expandableBadgeStylesheet.pressableExpandedAttached,
|
||||
],
|
||||
[borderlessWhenExpanded, isExpanded, isInteractive, isPressed],
|
||||
);
|
||||
|
||||
@@ -10,12 +10,6 @@ import { QuestionFormCard } from "./question-form-card";
|
||||
// Load translations so controls expose their real accessible names.
|
||||
void testI18n;
|
||||
|
||||
// The Markdown engine does not load in this harness; question and option text are covered by
|
||||
// e2e/browser/question-prompt-pagination.spec.ts. Plain text keeps these tests on the answers.
|
||||
vi.mock("@/components/markdown/renderer", () => ({
|
||||
MarkdownRenderer: ({ text }: { text: string }) => <span>{text}</span>,
|
||||
}));
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useState, useCallback, useMemo, useRef, type RefObject } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { Check, X } from "lucide-react-native";
|
||||
@@ -18,7 +12,6 @@ import { isWeb } from "@/constants/platform";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input/types";
|
||||
import { ScrollableCodeSurface } from "@/components/ui/scrollable-code-surface";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import {
|
||||
areQuestionsAnswered,
|
||||
buildQuestionFormAnswers,
|
||||
@@ -43,20 +36,6 @@ const IS_WEB = isWeb;
|
||||
// drag to the preview's nested scroll, so lines past a cap would be unreachable.
|
||||
const OPTION_PREVIEW_MAX_HEIGHT = IS_WEB ? 240 : undefined;
|
||||
|
||||
// Web clicks that must not answer: a link in the label, anything in the description, and the
|
||||
// second click of a double-click, which would otherwise land on the next question's options.
|
||||
function isNonAnsweringClick(event: GestureResponderEvent, description: View | null): boolean {
|
||||
if (!IS_WEB) return false;
|
||||
const click = event.nativeEvent as unknown as MouseEvent;
|
||||
if (click.detail > 1) return true;
|
||||
const target = click.target as Element | null;
|
||||
const descriptionElement = description as unknown as Element | null;
|
||||
return Boolean(target?.closest("a") || descriptionElement?.contains(target));
|
||||
}
|
||||
|
||||
// The description is selectable text, so a touch on it belongs to the text, not the option.
|
||||
const claimResponder = () => true;
|
||||
|
||||
function getQuestionInputPlaceholder({
|
||||
question,
|
||||
answerPlaceholder,
|
||||
@@ -92,14 +71,9 @@ function QuestionOptionRow({
|
||||
}: QuestionOptionRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const descriptionRef = useRef<View>(null);
|
||||
const handlePress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
if (isNonAnsweringClick(event, descriptionRef.current)) return;
|
||||
onToggle(qIndex, optIndex, multiSelect);
|
||||
},
|
||||
[onToggle, qIndex, optIndex, multiSelect],
|
||||
);
|
||||
const handlePress = useCallback(() => {
|
||||
onToggle(qIndex, optIndex, multiSelect);
|
||||
}, [onToggle, qIndex, optIndex, multiSelect]);
|
||||
|
||||
const pressableStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
@@ -112,6 +86,17 @@ function QuestionOptionRow({
|
||||
[isSelected, theme.colors.surface2],
|
||||
);
|
||||
|
||||
const optionLabelStyle = useMemo(
|
||||
() => [
|
||||
styles.optionLabel,
|
||||
{ color: isSelected ? theme.colors.foreground : theme.colors.foregroundMuted },
|
||||
],
|
||||
[isSelected, theme.colors.foreground, theme.colors.foregroundMuted],
|
||||
);
|
||||
const optionDescriptionStyle = useMemo(
|
||||
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
const accessibilityState = useMemo(() => ({ checked: isSelected }), [isSelected]);
|
||||
|
||||
// Static left-side control: square for multi-select, circle for single-select.
|
||||
@@ -150,15 +135,9 @@ function QuestionOptionRow({
|
||||
{isSelected && !multiSelect ? <View style={radioDotStyle} /> : null}
|
||||
</View>
|
||||
<View style={styles.optionTextBlock}>
|
||||
<MarkdownRenderer text={option.label} compact muted={!isSelected} selectable={false} />
|
||||
<Text style={optionLabelStyle}>{option.label}</Text>
|
||||
{option.description ? (
|
||||
<View
|
||||
ref={descriptionRef}
|
||||
testID="question-form-option-description"
|
||||
onStartShouldSetResponder={claimResponder}
|
||||
>
|
||||
<MarkdownRenderer text={option.description} compact muted />
|
||||
</View>
|
||||
<Text style={optionDescriptionStyle}>{option.description}</Text>
|
||||
) : null}
|
||||
{option.preview ? (
|
||||
// Shown for every option at once so they can be compared before picking.
|
||||
@@ -543,6 +522,10 @@ export function QuestionFormCard({
|
||||
],
|
||||
[theme.colors.surface1, theme.colors.border],
|
||||
);
|
||||
const questionTextStyle = useMemo(
|
||||
() => [styles.questionText, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
);
|
||||
// Single-select radios need a group; checkboxes are valid standalone.
|
||||
const optionsGroupAccessibility = useMemo(
|
||||
() =>
|
||||
@@ -587,9 +570,9 @@ export function QuestionFormCard({
|
||||
onSelect={handleSelectQuestion}
|
||||
/>
|
||||
<View style={styles.questionHeader}>
|
||||
<View testID="question-form-current-question" style={styles.questionText}>
|
||||
<MarkdownRenderer text={questions[resolvedActiveQuestionIndex].question} compact />
|
||||
</View>
|
||||
<Text testID="question-form-current-question" style={questionTextStyle}>
|
||||
{activeQuestion?.question}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{activeQuestion ? (
|
||||
@@ -692,6 +675,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
questionText: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 22,
|
||||
},
|
||||
optionsWrap: {
|
||||
gap: theme.spacing[1],
|
||||
@@ -737,6 +723,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
optionLabel: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 22,
|
||||
},
|
||||
optionDescription: {
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 20,
|
||||
},
|
||||
optionPreviewText: {
|
||||
lineHeight: 20,
|
||||
...(isWeb ? { whiteSpace: "pre-wrap", overflowWrap: "break-word" } : null),
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { createWorkspaceVisibilityPolicy } from "@/components/sidebar/sidebar-group-visibility";
|
||||
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages";
|
||||
@@ -487,20 +486,6 @@ describe("sidebar group visibility", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("treats placements without a hydrated entry as Done", () => {
|
||||
const items = entries([...Array(8).fill("done"), "running", "done"]);
|
||||
const hydrated = new Map(items.slice(0, 9).map((item) => [item.workspaceKey, item]));
|
||||
for (const entriesByKey of [new Map(), hydrated]) {
|
||||
const { result } = renderHook(() =>
|
||||
useLimitedSidebarGroup(items, createWorkspaceVisibilityPolicy(entriesByKey)),
|
||||
);
|
||||
expect(result.current.canToggle).toBe(true);
|
||||
expect(names(result.current.visibleItems)).toEqual(
|
||||
entriesByKey.size === 0 ? names(items.slice(0, 7)) : [...names(items.slice(0, 7)), "row-9"],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows eight active rows once with no redundant toggle", () => {
|
||||
const items = entries([
|
||||
"running",
|
||||
|
||||
@@ -99,7 +99,6 @@ import {
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
|
||||
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
||||
import { createWorkspaceVisibilityPolicy } from "@/components/sidebar/sidebar-group-visibility";
|
||||
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||
import {
|
||||
SidebarWorkspaceRowFrame,
|
||||
@@ -1551,11 +1550,6 @@ function ProjectBlock({
|
||||
shortcutIndexByWorkspaceKey,
|
||||
onToggleCollapsed,
|
||||
onWorkspacePress,
|
||||
parentGestureRef,
|
||||
onWorkspaceReorder,
|
||||
useNestable,
|
||||
dragGestureHostActive,
|
||||
manualSort,
|
||||
onWorktreeCreated,
|
||||
drag,
|
||||
isDragging,
|
||||
@@ -1577,11 +1571,6 @@ function ProjectBlock({
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>;
|
||||
onToggleCollapsed: (projectViewKey: string) => void;
|
||||
onWorkspacePress?: () => void;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void;
|
||||
useNestable: boolean;
|
||||
dragGestureHostActive?: boolean;
|
||||
manualSort: boolean;
|
||||
onWorktreeCreated?: (workspaceId: string) => void;
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
@@ -1594,7 +1583,11 @@ function ProjectBlock({
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
}) {
|
||||
const visibilityPolicy = useMemo(
|
||||
() => createWorkspaceVisibilityPolicy<SidebarWorkspacePlacement>(workspaceEntriesByKey),
|
||||
() => ({
|
||||
kind: "workspaces" as const,
|
||||
getStatusBucket: (workspace: SidebarWorkspacePlacement) =>
|
||||
workspaceEntriesByKey.get(workspace.workspaceKey)!.statusBucket,
|
||||
}),
|
||||
[workspaceEntriesByKey],
|
||||
);
|
||||
const {
|
||||
@@ -1631,14 +1624,7 @@ function ProjectBlock({
|
||||
});
|
||||
|
||||
const renderWorkspaceRow = useCallback(
|
||||
(
|
||||
item: SidebarWorkspacePlacement,
|
||||
input?: {
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
},
|
||||
) => {
|
||||
(item: SidebarWorkspacePlacement) => {
|
||||
return (
|
||||
<MemoWorkspaceRowItem
|
||||
key={item.workspaceKey}
|
||||
@@ -1654,9 +1640,6 @@ function ProjectBlock({
|
||||
selectionEnabled={selectionEnabled}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
drag={input?.drag}
|
||||
isDragging={input?.isDragging}
|
||||
dragHandleProps={input?.dragHandleProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -1675,29 +1658,6 @@ function ProjectBlock({
|
||||
],
|
||||
);
|
||||
|
||||
const renderWorkspace = useCallback(
|
||||
({
|
||||
item,
|
||||
drag: workspaceDrag,
|
||||
isActive,
|
||||
dragHandleProps: workspaceDragHandleProps,
|
||||
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
|
||||
return renderWorkspaceRow(item, {
|
||||
drag: workspaceDrag,
|
||||
isDragging: isActive,
|
||||
dragHandleProps: workspaceDragHandleProps,
|
||||
});
|
||||
},
|
||||
[renderWorkspaceRow],
|
||||
);
|
||||
|
||||
const handleWorkspaceDragEnd = useCallback(
|
||||
(workspaces: SidebarWorkspacePlacement[]) => {
|
||||
onWorkspaceReorder(project.viewKey, workspaces);
|
||||
},
|
||||
[onWorkspaceReorder, project.viewKey],
|
||||
);
|
||||
|
||||
const toast = useToast();
|
||||
const { t } = useTranslation();
|
||||
const [isRemovingProject, setIsRemovingProject] = useState(false);
|
||||
@@ -1763,29 +1723,12 @@ function ProjectBlock({
|
||||
if (project.workspaces.length > 0) {
|
||||
projectChildren = (
|
||||
<>
|
||||
{manualSort ? (
|
||||
<DraggableList
|
||||
testID={`sidebar-workspace-list-${project.viewKey}`}
|
||||
data={visibleWorkspaces}
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
testID={`sidebar-workspace-list-${project.viewKey}`}
|
||||
style={styles.workspaceListContainer}
|
||||
>
|
||||
{visibleWorkspaces.map((item) => renderWorkspaceRow(item))}
|
||||
</View>
|
||||
)}
|
||||
<View
|
||||
testID={`sidebar-workspace-list-${project.viewKey}`}
|
||||
style={styles.workspaceListContainer}
|
||||
>
|
||||
{visibleWorkspaces.map(renderWorkspaceRow)}
|
||||
</View>
|
||||
{canToggleWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={workspacesExpanded}
|
||||
@@ -1860,11 +1803,6 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
|
||||
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
|
||||
previous.onToggleCollapsed === next.onToggleCollapsed &&
|
||||
previous.onWorkspacePress === next.onWorkspacePress &&
|
||||
previous.parentGestureRef === next.parentGestureRef &&
|
||||
previous.onWorkspaceReorder === next.onWorkspaceReorder &&
|
||||
previous.useNestable === next.useNestable &&
|
||||
previous.dragGestureHostActive === next.dragGestureHostActive &&
|
||||
previous.manualSort === next.manualSort &&
|
||||
previous.onWorktreeCreated === next.onWorktreeCreated &&
|
||||
previous.drag === next.drag &&
|
||||
previous.isDragging === next.isDragging &&
|
||||
@@ -2174,9 +2112,6 @@ function ProjectModeList({
|
||||
(state) => state.togglePinnedCollapsed,
|
||||
);
|
||||
|
||||
const manualSort = useSidebarViewStore((state) => state.workspaceSort === "manual");
|
||||
const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder);
|
||||
const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder);
|
||||
const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
|
||||
const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
|
||||
|
||||
@@ -2276,30 +2211,6 @@ function ProjectModeList({
|
||||
[getProjectOrder, setProjectOrder],
|
||||
);
|
||||
|
||||
const handleWorkspaceReorder = useCallback(
|
||||
(projectViewKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => {
|
||||
const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey);
|
||||
const currentWorkspaceOrder = getWorkspaceOrder(projectViewKey);
|
||||
if (
|
||||
!hasVisibleOrderChanged({
|
||||
currentOrder: currentWorkspaceOrder,
|
||||
reorderedVisibleKeys: reorderedWorkspaceKeys,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWorkspaceOrder(
|
||||
projectViewKey,
|
||||
mergeWithRemainder({
|
||||
currentOrder: currentWorkspaceOrder,
|
||||
reorderedVisibleKeys: reorderedWorkspaceKeys,
|
||||
}),
|
||||
);
|
||||
},
|
||||
[getWorkspaceOrder, setWorkspaceOrder],
|
||||
);
|
||||
|
||||
const handleWorktreeCreated = useCallback((workspaceId: string) => {
|
||||
setCreatingWorkspaceIds((current) => {
|
||||
const next = new Set(current);
|
||||
@@ -2348,11 +2259,6 @@ function ProjectModeList({
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
onToggleCollapsed={onToggleProjectCollapsed}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
manualSort={manualSort}
|
||||
parentGestureRef={parentGestureRef}
|
||||
onWorkspaceReorder={handleWorkspaceReorder}
|
||||
useNestable={platformIsNative}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
onWorktreeCreated={handleWorktreeCreated}
|
||||
drag={dragState.drag}
|
||||
isDragging={dragState.isDragging}
|
||||
@@ -2370,10 +2276,6 @@ function ProjectModeList({
|
||||
collapsedProjectKeys,
|
||||
activeWorkspaceSelection,
|
||||
handleWorktreeCreated,
|
||||
manualSort,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
handleWorkspaceReorder,
|
||||
hostBadgeByServerId,
|
||||
supportsMultiplicityByServerId,
|
||||
supportsPinningByServerId,
|
||||
|
||||
@@ -244,8 +244,10 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
definitions.push({
|
||||
id: "sort",
|
||||
title: t("sidebar.display.sort.label"),
|
||||
content: (
|
||||
<SortPage preferences={preferences} automaticSortSupported={workspaceSortSupported} />
|
||||
content: workspaceSortSupported ? (
|
||||
<SortPage preferences={preferences} />
|
||||
) : (
|
||||
<MenuItem disabled>{t("sidebar.display.sort.updateHost")}</MenuItem>
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -323,7 +325,7 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
<MenuSubTrigger
|
||||
id="sort"
|
||||
value={
|
||||
workspaceSortSupported || preferences.workspaceSort === "manual"
|
||||
workspaceSortSupported
|
||||
? t(`sidebar.display.sort.${preferences.workspaceSort}`)
|
||||
: undefined
|
||||
}
|
||||
@@ -397,38 +399,19 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
function SortPage({
|
||||
preferences,
|
||||
automaticSortSupported,
|
||||
}: {
|
||||
preferences: Preferences;
|
||||
automaticSortSupported: boolean;
|
||||
}): ReactElement {
|
||||
function SortPage({ preferences }: { preferences: Preferences }): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { setWorkspaceSort } = preferences;
|
||||
const selectManual = useCallback(() => setWorkspaceSort("manual"), [setWorkspaceSort]);
|
||||
const selectCreated = useCallback(() => setWorkspaceSort("created"), [setWorkspaceSort]);
|
||||
const selectLastPrompt = useCallback(() => setWorkspaceSort("lastPrompt"), [setWorkspaceSort]);
|
||||
return (
|
||||
<>
|
||||
<MenuItem selected={preferences.workspaceSort === "manual"} onSelect={selectManual}>
|
||||
{t("sidebar.display.sort.manual")}
|
||||
<MenuItem selected={preferences.workspaceSort === "created"} onSelect={selectCreated}>
|
||||
{t("sidebar.display.sort.created")}
|
||||
</MenuItem>
|
||||
<MenuItem selected={preferences.workspaceSort === "lastPrompt"} onSelect={selectLastPrompt}>
|
||||
{t("sidebar.display.sort.lastPrompt")}
|
||||
</MenuItem>
|
||||
{automaticSortSupported ? (
|
||||
<>
|
||||
<MenuItem selected={preferences.workspaceSort === "created"} onSelect={selectCreated}>
|
||||
{t("sidebar.display.sort.created")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
selected={preferences.workspaceSort === "lastPrompt"}
|
||||
onSelect={selectLastPrompt}
|
||||
>
|
||||
{t("sidebar.display.sort.lastPrompt")}
|
||||
</MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<MenuItem disabled>{t("sidebar.display.sort.updateHost")}</MenuItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,18 +4,6 @@ export type SidebarGroupVisibilityPolicy<T> =
|
||||
| { kind: "pinned" }
|
||||
| { kind: "workspaces"; getStatusBucket: (item: T) => SidebarStateBucket };
|
||||
|
||||
// A placement can outrun its entry: a retained sidebar that is inactive (compact layout with the
|
||||
// sidebar closed) or still waiting for sessions keeps an empty entry map. A missing entry renders
|
||||
// no row, so it counts as Done and claims no slot past the limit.
|
||||
export function createWorkspaceVisibilityPolicy<T extends { workspaceKey: string }>(
|
||||
entriesByKey: ReadonlyMap<string, { statusBucket: SidebarStateBucket }>,
|
||||
): SidebarGroupVisibilityPolicy<T> {
|
||||
return {
|
||||
kind: "workspaces",
|
||||
getStatusBucket: (item) => entriesByKey.get(item.workspaceKey)?.statusBucket ?? "done",
|
||||
};
|
||||
}
|
||||
|
||||
export function selectSidebarGroupVisibility<T>(
|
||||
items: readonly T[],
|
||||
expanded: boolean,
|
||||
|
||||
@@ -28,7 +28,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
@@ -911,9 +910,13 @@ function resolveInitialActiveIndex(
|
||||
return selectedIndex >= 0 ? selectedIndex : fallbackIndex;
|
||||
}
|
||||
|
||||
type BottomSheetVisibility = ReturnType<typeof useIsolatedBottomSheetVisibility>;
|
||||
|
||||
interface MobileBodyProps {
|
||||
isOpen: boolean;
|
||||
bottomSheetRef: BottomSheetVisibility["sheetRef"];
|
||||
snapPoints: string[];
|
||||
handleSheetChange: BottomSheetVisibility["handleSheetChange"];
|
||||
handleSheetDismiss: BottomSheetVisibility["handleSheetDismiss"];
|
||||
handleIndicatorStyle: { backgroundColor: string };
|
||||
titleColor: string;
|
||||
title: string;
|
||||
@@ -941,22 +944,7 @@ interface MobileBodyProps {
|
||||
safeAreaBottom: number;
|
||||
}
|
||||
|
||||
function MobileComboboxBody(props: MobileBodyProps): ReactElement | null {
|
||||
const active = useRetainedPanelActive();
|
||||
if (!active) return null;
|
||||
// The visibility tracker must leave the tree with its sheet.
|
||||
return <MobileComboboxSheet {...props} />;
|
||||
}
|
||||
|
||||
function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
|
||||
const {
|
||||
sheetRef: bottomSheetRef,
|
||||
handleSheetChange,
|
||||
handleSheetDismiss,
|
||||
} = useIsolatedBottomSheetVisibility({
|
||||
visible: props.isOpen,
|
||||
onClose: props.onClose,
|
||||
});
|
||||
function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
||||
const comboboxTitleStyle = useMemo(
|
||||
() => [styles.comboboxTitle, { color: props.titleColor }],
|
||||
[props.titleColor],
|
||||
@@ -991,13 +979,13 @@ function MobileComboboxSheet(props: MobileBodyProps): ReactElement {
|
||||
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={bottomSheetRef}
|
||||
ref={props.bottomSheetRef}
|
||||
contextBridge={null}
|
||||
snapPoints={props.snapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
onChange={props.handleSheetChange}
|
||||
onDismiss={props.handleSheetDismiss}
|
||||
backdropOpacity={0.45}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={ComboboxSheetBackground}
|
||||
@@ -1180,8 +1168,7 @@ function DesktopComboboxOptionsBody(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
|
||||
const active = useRetainedPanelActive();
|
||||
function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
|
||||
const handleDesktopKey = props.handleDesktopKey;
|
||||
const handleWebOverlayKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
@@ -1203,9 +1190,6 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null {
|
||||
[props.refs, setWebOverlayScope],
|
||||
);
|
||||
|
||||
// Remove the portal before a retained chat can freeze its closing update.
|
||||
if (!active) return null;
|
||||
|
||||
const overlay = (
|
||||
<OverlayLayerProvider layer={props.overlayLayer}>
|
||||
<View
|
||||
@@ -1325,7 +1309,6 @@ export function Combobox({
|
||||
const resolvedEmptyText = emptyText ?? t("common.empty.noOptionsMatchSearch");
|
||||
const resolvedTitle = title ?? t("common.actions.select");
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const active = useRetainedPanelActive();
|
||||
const floatingLayer = useOverlayLayer("floating");
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const titleColor = theme.colors.foreground;
|
||||
@@ -1365,10 +1348,6 @@ export function Combobox({
|
||||
bumpSearchResetKey();
|
||||
}, [setOpen, setSearchQueryWithCallback]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active && isOpen) handleClose();
|
||||
}, [active, isOpen, handleClose]);
|
||||
|
||||
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey);
|
||||
|
||||
const collisionPadding = useMemo(computeCollisionPadding, []);
|
||||
@@ -1434,6 +1413,16 @@ export function Combobox({
|
||||
referenceWidth,
|
||||
});
|
||||
|
||||
const {
|
||||
sheetRef: bottomSheetRef,
|
||||
handleSheetChange,
|
||||
handleSheetDismiss,
|
||||
} = useIsolatedBottomSheetVisibility({
|
||||
visible: isOpen,
|
||||
isEnabled: isMobile,
|
||||
onClose: handleClose,
|
||||
});
|
||||
|
||||
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : "";
|
||||
const sanitizedSearchValue = searchQuery.trim();
|
||||
const showCustomOption = useMemo(
|
||||
@@ -1583,8 +1572,10 @@ export function Combobox({
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileComboboxBody
|
||||
isOpen={isOpen}
|
||||
bottomSheetRef={bottomSheetRef}
|
||||
snapPoints={snapPoints}
|
||||
handleSheetChange={handleSheetChange}
|
||||
handleSheetDismiss={handleSheetDismiss}
|
||||
handleIndicatorStyle={handleIndicatorStyle}
|
||||
titleColor={titleColor}
|
||||
title={resolvedTitle}
|
||||
|
||||
@@ -27,7 +27,6 @@ import { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { FloatingSurface } from "@/components/ui/floating";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
|
||||
|
||||
@@ -257,15 +256,8 @@ export function Tooltip({
|
||||
});
|
||||
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const active = useRetainedPanelActive();
|
||||
const opensOnPress = isNative || isCompact;
|
||||
const enabled = active && (opensOnPress ? enabledOnMobile : enabledOnDesktop);
|
||||
|
||||
// Remove the portal in the inactive commit, before the retained chat freezes.
|
||||
// Closing also prevents the tooltip from returning when its panel is shown again.
|
||||
useEffect(() => {
|
||||
if (!active && isOpen) setIsOpen(false);
|
||||
}, [active, isOpen, setIsOpen]);
|
||||
const enabled = opensOnPress ? enabledOnMobile : enabledOnDesktop;
|
||||
|
||||
const value = useMemo<TooltipContextValue>(
|
||||
() => ({
|
||||
@@ -337,10 +329,6 @@ export function TooltipTrigger({
|
||||
};
|
||||
}, [clearOpenTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx.enabled) clearOpenTimer();
|
||||
}, [ctx.enabled, clearOpenTimer]);
|
||||
|
||||
const handleHoverIn = useCallback(
|
||||
(e?: unknown) => {
|
||||
if (isCallable(onHoverIn)) onHoverIn(e);
|
||||
|
||||
@@ -55,11 +55,7 @@ import {
|
||||
} from "@/composer/agent-controls";
|
||||
import { ContextWindowMeter } from "@/components/context-window-meter";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import {
|
||||
selectAgentSentMessages,
|
||||
selectAgentTurnPresentation,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
|
||||
import { useFilePicker } from "@/hooks/use-file-picker";
|
||||
import { useFileDrop } from "@/components/file-drop/use-file-drop";
|
||||
import type { DroppedItem } from "@/components/file-drop/types";
|
||||
@@ -122,7 +118,6 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
import { submitAgentInput } from "@/composer/submit";
|
||||
import { ComposerRecall } from "@/composer/recall";
|
||||
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
|
||||
import { ComposerKeyboardScopeProvider, useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
@@ -1000,7 +995,6 @@ interface ComposerAutocompleteHandle {
|
||||
function ComposerAutocompleteBinding({
|
||||
text,
|
||||
cursor,
|
||||
recall,
|
||||
configuration,
|
||||
inputRef,
|
||||
anchorRef,
|
||||
@@ -1009,7 +1003,6 @@ function ComposerAutocompleteBinding({
|
||||
}: {
|
||||
text: ComposerTextSource;
|
||||
cursor: StoreApi<number>;
|
||||
recall: ComposerRecall;
|
||||
configuration: Omit<
|
||||
Parameters<typeof useAgentAutocomplete>[0],
|
||||
"userInput" | "cursorIndex" | "onAutocompleteApplied"
|
||||
@@ -1027,11 +1020,9 @@ function ComposerAutocompleteBinding({
|
||||
cursorIndex: Math.min(cursorIndex, userInput.length),
|
||||
onAutocompleteApplied: () => inputRef.current?.focus(),
|
||||
});
|
||||
// A recalled message is sent as is; its leading `/command` must not open the picker.
|
||||
const isShown = show && !recall.isShowing(userInput);
|
||||
const handleKeyPress = autocomplete.onKeyPress;
|
||||
useImperativeHandle(ref, () => ({ onKeyPress: (event) => isShown && handleKeyPress(event) }), [
|
||||
isShown,
|
||||
useImperativeHandle(ref, () => ({ onKeyPress: (event) => show && handleKeyPress(event) }), [
|
||||
show,
|
||||
handleKeyPress,
|
||||
]);
|
||||
const selectOption = autocomplete.onSelectOption;
|
||||
@@ -1041,7 +1032,7 @@ function ComposerAutocompleteBinding({
|
||||
);
|
||||
return (
|
||||
<ComposerAutocomplete
|
||||
visible={autocomplete.isVisible && isShown}
|
||||
visible={autocomplete.isVisible && show}
|
||||
anchorRef={anchorRef}
|
||||
options={autocomplete.options}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
@@ -1371,7 +1362,6 @@ function ComposerContentImpl({
|
||||
);
|
||||
useEffect(() => () => cursorPublication.cancel(), [cursorPublication]);
|
||||
const autocompleteRef = useRef<ComposerAutocompleteHandle>(null);
|
||||
const recall = useMemo(() => new ComposerRecall(), []);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [pendingFiles, setPendingFiles] = useState<PendingFileAttachment[]>([]);
|
||||
const nextPendingFileId = useRef(0);
|
||||
@@ -1991,19 +1981,10 @@ function ComposerContentImpl({
|
||||
|
||||
const hasSendableContent = hasText || selectedAttachments.length > 0;
|
||||
|
||||
// An open autocomplete owns the arrows; otherwise they may recall sent messages.
|
||||
// Handle keyboard navigation for command autocomplete.
|
||||
const handleCommandKeyPress = useCallback(
|
||||
(event: ComposerKeyPressEvent) => {
|
||||
if (autocompleteRef.current?.onKeyPress(event)) return true;
|
||||
const recalledText = recall.handleKeyPress(event, () =>
|
||||
selectAgentSentMessages(useSessionStore.getState().sessions[serverId], agentId),
|
||||
);
|
||||
if (recalledText === null) return false;
|
||||
event.preventDefault();
|
||||
replaceUserInput(recalledText);
|
||||
return true;
|
||||
},
|
||||
[agentId, recall, replaceUserInput, serverId],
|
||||
(event: ComposerKeyPressEvent) => autocompleteRef.current?.onKeyPress(event) ?? false,
|
||||
[],
|
||||
);
|
||||
|
||||
const cancelButtonStyle = useMemo(
|
||||
@@ -2442,7 +2423,6 @@ function ComposerContentImpl({
|
||||
<ComposerAutocompleteBinding
|
||||
text={textSource}
|
||||
cursor={cursor}
|
||||
recall={recall}
|
||||
inputRef={messageInputRef}
|
||||
anchorRef={messageInputContainerRef}
|
||||
show={mode.showAutocomplete && !isFullscreen}
|
||||
|
||||
@@ -110,8 +110,6 @@ export interface ComposerInputSnapshot {
|
||||
|
||||
export interface ComposerKeyPressEvent {
|
||||
key: string;
|
||||
/** Shift, Alt, Ctrl, or Meta was held. */
|
||||
hasModifier: boolean;
|
||||
preventDefault: () => void;
|
||||
input: ComposerInputSnapshot;
|
||||
}
|
||||
@@ -212,7 +210,6 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
||||
metaKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
altKey?: boolean;
|
||||
// Web-only: present on DOM KeyboardEvent during IME composition (CJK input).
|
||||
isComposing?: boolean;
|
||||
keyCode?: number;
|
||||
@@ -414,18 +411,17 @@ function handleDesktopKeyPressImpl(
|
||||
): void {
|
||||
if (isImeComposingKeyboardEvent(event.nativeEvent)) return;
|
||||
|
||||
const { shiftKey, metaKey, ctrlKey, altKey } = event.nativeEvent;
|
||||
|
||||
if (ctx.onKeyPressCallback) {
|
||||
const handled = ctx.onKeyPressCallback({
|
||||
key: event.nativeEvent.key,
|
||||
hasModifier: Boolean(shiftKey || metaKey || ctrlKey || altKey),
|
||||
preventDefault: () => event.preventDefault(),
|
||||
input: ctx.input,
|
||||
});
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
|
||||
|
||||
if (event.nativeEvent.key !== "Enter") return;
|
||||
if (!ctx.submitOnEnter) return;
|
||||
if (shiftKey) return;
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ComposerKeyPressEvent } from "@/composer/input/input";
|
||||
import { ComposerRecall } from "@/composer/recall";
|
||||
|
||||
const SENT = ["add tests", "fix the auth bug"];
|
||||
|
||||
function press(
|
||||
key: string,
|
||||
text: string,
|
||||
options: { caret?: number; selectionEnd?: number; hasModifier?: boolean } = {},
|
||||
): ComposerKeyPressEvent {
|
||||
const caret = options.caret ?? text.length;
|
||||
return {
|
||||
key,
|
||||
hasModifier: options.hasModifier ?? false,
|
||||
preventDefault: () => undefined,
|
||||
input: { text, selection: { start: caret, end: options.selectionEnd ?? caret } },
|
||||
};
|
||||
}
|
||||
|
||||
/** Drives recall the way the composer does: each result becomes the composer text, caret at the end. */
|
||||
function composerWithRecall(sent: readonly string[] = SENT) {
|
||||
const recall = new ComposerRecall();
|
||||
let text = "";
|
||||
return {
|
||||
recall,
|
||||
get text() {
|
||||
return text;
|
||||
},
|
||||
type(next: string) {
|
||||
text = next;
|
||||
},
|
||||
press(key: string, options: { caret?: number; hasModifier?: boolean } = {}) {
|
||||
const result = recall.handleKeyPress(press(key, text, options), () => sent);
|
||||
if (result !== null) text = result;
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ComposerRecall", () => {
|
||||
it("walks back through sent messages and forward to an empty composer", () => {
|
||||
const composer = composerWithRecall();
|
||||
|
||||
expect(composer.press("ArrowUp")).toBe("add tests");
|
||||
expect(composer.press("ArrowUp")).toBe("fix the auth bug");
|
||||
expect(composer.press("ArrowDown")).toBe("add tests");
|
||||
expect(composer.press("ArrowDown")).toBe("");
|
||||
expect(composer.recall.isShowing(composer.text)).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves the caret to the input past the oldest message", () => {
|
||||
const composer = composerWithRecall();
|
||||
composer.press("ArrowUp");
|
||||
composer.press("ArrowUp");
|
||||
|
||||
expect(composer.press("ArrowUp")).toBeNull();
|
||||
expect(composer.text).toBe("fix the auth bug");
|
||||
});
|
||||
|
||||
it("only starts from an empty composer", () => {
|
||||
const composer = composerWithRecall();
|
||||
composer.type("half written");
|
||||
|
||||
expect(composer.press("ArrowUp")).toBeNull();
|
||||
expect(composer.press("ArrowDown")).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores Down from an empty composer and Up with no history", () => {
|
||||
expect(composerWithRecall().press("ArrowDown")).toBeNull();
|
||||
expect(composerWithRecall([]).press("ArrowUp")).toBeNull();
|
||||
});
|
||||
|
||||
it("hands the arrows back to the caret once the recalled message is edited", () => {
|
||||
const composer = composerWithRecall();
|
||||
composer.press("ArrowUp");
|
||||
composer.type("add tests for login");
|
||||
|
||||
expect(composer.recall.isShowing(composer.text)).toBe(false);
|
||||
expect(composer.press("ArrowUp")).toBeNull();
|
||||
expect(composer.press("ArrowDown")).toBeNull();
|
||||
});
|
||||
|
||||
it("moves between lines of a multi-line message before changing message", () => {
|
||||
const composer = composerWithRecall(["first line\nsecond line", "older"]);
|
||||
composer.press("ArrowUp");
|
||||
|
||||
expect(composer.press("ArrowUp", { caret: "first line\nsec".length })).toBeNull();
|
||||
expect(composer.press("ArrowUp", { caret: 3 })).toBe("older");
|
||||
expect(composer.press("ArrowDown")).toBe("first line\nsecond line");
|
||||
expect(composer.press("ArrowDown", { caret: 3 })).toBeNull();
|
||||
expect(composer.press("ArrowDown")).toBe("");
|
||||
});
|
||||
|
||||
it("ignores modified arrows, other keys, and a selection", () => {
|
||||
const composer = composerWithRecall();
|
||||
|
||||
expect(composer.press("ArrowUp", { hasModifier: true })).toBeNull();
|
||||
expect(composer.press("Enter")).toBeNull();
|
||||
composer.press("ArrowUp");
|
||||
const recall = composer.recall;
|
||||
expect(
|
||||
recall.handleKeyPress(
|
||||
press("ArrowUp", "add tests", { caret: 0, selectionEnd: 3 }),
|
||||
() => SENT,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { ComposerKeyPressEvent } from "@/composer/input/input";
|
||||
|
||||
interface RecalledMessage {
|
||||
index: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the messages already sent to an agent from an empty composer, like a
|
||||
* shell's Up/Down history. A message stays recalled only while the composer
|
||||
* still holds its exact text; the first edit hands the arrows back to the caret.
|
||||
*/
|
||||
export class ComposerRecall {
|
||||
private recalled: RecalledMessage | null = null;
|
||||
|
||||
/** True while the composer shows a recalled message the user has not edited. */
|
||||
isShowing(text: string): boolean {
|
||||
return this.recalled !== null && this.recalled.text === text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the text the composer should show for this key press, or null to
|
||||
* leave the key to the input. `readSentMessages` is newest first and is only
|
||||
* read for keys recall acts on.
|
||||
*/
|
||||
handleKeyPress(
|
||||
event: ComposerKeyPressEvent,
|
||||
readSentMessages: () => readonly string[],
|
||||
): string | null {
|
||||
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return null;
|
||||
if (event.hasModifier) return null;
|
||||
const { text, selection } = event.input;
|
||||
if (selection.start !== selection.end) return null;
|
||||
|
||||
const recalled = this.isShowing(text) ? this.recalled : null;
|
||||
if (!recalled) {
|
||||
this.recalled = null;
|
||||
if (event.key !== "ArrowUp" || text.length > 0) return null;
|
||||
return this.show(readSentMessages(), 0);
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
if (!isCaretOnFirstLine(text, selection.start)) return null;
|
||||
return this.show(readSentMessages(), recalled.index + 1);
|
||||
}
|
||||
if (!isCaretOnLastLine(text, selection.start)) return null;
|
||||
if (recalled.index === 0) {
|
||||
this.recalled = null;
|
||||
return "";
|
||||
}
|
||||
return this.show(readSentMessages(), recalled.index - 1);
|
||||
}
|
||||
|
||||
private show(messages: readonly string[], index: number): string | null {
|
||||
const text = messages[index];
|
||||
if (text === undefined) return null;
|
||||
this.recalled = { index, text };
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function isCaretOnFirstLine(text: string, caret: number): boolean {
|
||||
return !text.slice(0, caret).includes("\n");
|
||||
}
|
||||
|
||||
function isCaretOnLastLine(text: string, caret: number): boolean {
|
||||
return !text.slice(caret).includes("\n");
|
||||
}
|
||||
@@ -276,11 +276,7 @@ export function ComposerTrackRow({
|
||||
* knockout for marks that sit on top of an icon, and nothing sits under this one, so it comes off
|
||||
* too — leaving every mark's visible edge on the same rail whatever state it is in.
|
||||
*/
|
||||
export function ComposerTrackMark({
|
||||
bucket,
|
||||
}: {
|
||||
bucket: SidebarStateBucket | null;
|
||||
}): ReactElement | null {
|
||||
function ComposerTrackMark({ bucket }: { bucket: SidebarStateBucket | null }): ReactElement | null {
|
||||
if (!bucket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -38,10 +38,10 @@ const CATALOG_DATA = [
|
||||
title: "Auggie CLI",
|
||||
description:
|
||||
"Augment Code's powerful software agent, backed by industry-leading context engine",
|
||||
version: "0.36.0",
|
||||
version: "0.33.0",
|
||||
iconId: "auggie",
|
||||
installLink: "https://www.augmentcode.com/",
|
||||
command: ["npx", "-y", "@augmentcode/auggie@0.36.0", "--acp"],
|
||||
command: ["npx", "-y", "@augmentcode/auggie@0.33.0", "--acp"],
|
||||
env: {
|
||||
AUGMENT_DISABLE_AUTO_UPDATE: "1",
|
||||
},
|
||||
@@ -60,10 +60,10 @@ const CATALOG_DATA = [
|
||||
title: "Cline",
|
||||
description:
|
||||
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
version: "3.0.73",
|
||||
version: "3.0.46",
|
||||
iconId: "cline",
|
||||
installLink: "https://cline.bot/cli",
|
||||
command: ["npx", "-y", "cline@3.0.73", "--acp"],
|
||||
command: ["npx", "-y", "cline@3.0.46", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "codebuddy-code",
|
||||
@@ -123,10 +123,10 @@ const CATALOG_DATA = [
|
||||
id: "deepagents",
|
||||
title: "DeepAgents",
|
||||
description: "Batteries-included AI coding and general purpose agent powered by LangChain.",
|
||||
version: "0.1.36",
|
||||
version: "0.1.20",
|
||||
iconId: "deepagents",
|
||||
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
|
||||
command: ["npx", "-y", "deepagents-acp@0.1.36"],
|
||||
command: ["npx", "-y", "deepagents-acp@0.1.20"],
|
||||
},
|
||||
{
|
||||
id: "devin",
|
||||
@@ -141,29 +141,29 @@ const CATALOG_DATA = [
|
||||
id: "dimcode",
|
||||
title: "DimCode",
|
||||
description: "A coding agent that puts leading models at your command.",
|
||||
version: "0.5.21",
|
||||
version: "0.2.36",
|
||||
iconId: "dimcode",
|
||||
installLink: "https://dimcode.dev/docs/acp.html",
|
||||
command: ["npx", "-y", "dimcode@0.5.21", "acp"],
|
||||
command: ["npx", "-y", "dimcode@0.2.36", "acp"],
|
||||
},
|
||||
{
|
||||
id: "dirac",
|
||||
title: "Dirac",
|
||||
description:
|
||||
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
|
||||
version: "0.5.23",
|
||||
version: "0.4.22",
|
||||
iconId: "dirac",
|
||||
installLink: "https://dirac.run",
|
||||
command: ["npx", "-y", "dirac-cli@0.5.23", "--acp"],
|
||||
command: ["npx", "-y", "dirac-cli@0.4.22", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "factory-droid",
|
||||
title: "Factory Droid",
|
||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||
version: "0.249.0",
|
||||
version: "0.179.0",
|
||||
iconId: "factory-droid",
|
||||
installLink: "https://factory.ai/product/cli",
|
||||
command: ["npx", "-y", "droid@0.249.0", "exec", "--output-format", "acp-daemon"],
|
||||
command: ["npx", "-y", "droid@0.179.0", "exec", "--output-format", "acp-daemon"],
|
||||
env: {
|
||||
DROID_DISABLE_AUTO_UPDATE: "true",
|
||||
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
||||
@@ -174,19 +174,19 @@ const CATALOG_DATA = [
|
||||
id: "fast-agent",
|
||||
title: "fast-agent",
|
||||
description: "Code and build agents with comprehensive multi-provider support",
|
||||
version: "0.10.47",
|
||||
version: "0.9.22",
|
||||
iconId: "fast-agent",
|
||||
installLink: "https://fast-agent.ai/acp/",
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.10.47", "fast-agent-acp", "-x"],
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.9.22", "fast-agent-acp", "-x"],
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
title: "Gemini CLI",
|
||||
description: "Google's official CLI for Gemini",
|
||||
version: "0.65.0",
|
||||
version: "0.52.0",
|
||||
iconId: "gemini",
|
||||
installLink: "https://geminicli.com",
|
||||
command: ["npx", "-y", "@google/gemini-cli@0.65.0", "--acp"],
|
||||
command: ["npx", "-y", "@google/gemini-cli@0.52.0", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "gjc",
|
||||
@@ -205,11 +205,11 @@ const CATALOG_DATA = [
|
||||
id: "glm-acp-agent",
|
||||
title: "GLM Agent",
|
||||
description:
|
||||
"ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.3, glm-5.3-flash, glm-5-turbo, glm-4.7). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.",
|
||||
version: "1.15.0",
|
||||
"ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.",
|
||||
version: "1.3.0",
|
||||
iconId: "glm-acp-agent",
|
||||
installLink: "https://github.com/stefandevo/glm-acp-agent",
|
||||
command: ["npx", "-y", "glm-acp-agent@1.15.0"],
|
||||
command: ["npx", "-y", "glm-acp-agent@1.3.0"],
|
||||
},
|
||||
{
|
||||
id: "goose",
|
||||
@@ -289,20 +289,20 @@ const CATALOG_DATA = [
|
||||
id: "minimax-code",
|
||||
title: "MiniMax Code",
|
||||
description: "MiniMax's coding agent for the terminal",
|
||||
version: "0.6.13",
|
||||
version: "0.1.2",
|
||||
iconId: "minimax-code",
|
||||
installLink: "https://agent.minimax.io",
|
||||
command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"],
|
||||
command: ["npx", "-y", "@minimax-ai/code@0.1.2", "acp"],
|
||||
},
|
||||
{
|
||||
id: "minion-code",
|
||||
title: "Minion Code",
|
||||
description:
|
||||
"An enhanced AI code assistant built on the Minion framework with rich development tools",
|
||||
version: "0.1.45",
|
||||
version: "0.1.44",
|
||||
iconId: "minion-code",
|
||||
installLink: "https://github.com/femto/minion-code",
|
||||
command: ["uvx", "--from", "minion-code==0.1.45", "minion-code", "acp"],
|
||||
command: ["uvx", "--from", "minion-code==0.1.44", "minion-code", "acp"],
|
||||
},
|
||||
{
|
||||
id: "mistral-vibe",
|
||||
@@ -317,10 +317,10 @@ const CATALOG_DATA = [
|
||||
id: "nova",
|
||||
title: "Nova",
|
||||
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
||||
version: "1.1.59",
|
||||
version: "1.1.29",
|
||||
iconId: "nova",
|
||||
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.59", "acp"],
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.29", "acp"],
|
||||
},
|
||||
{
|
||||
id: "poolside",
|
||||
@@ -335,19 +335,19 @@ const CATALOG_DATA = [
|
||||
id: "qoder",
|
||||
title: "Qoder CLI",
|
||||
description: "AI coding assistant with agentic capabilities",
|
||||
version: "1.1.78",
|
||||
version: "1.1.4",
|
||||
iconId: "qoder",
|
||||
installLink: "https://qoder.com",
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.78", "--acp"],
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.4", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "qwen-code",
|
||||
title: "Qwen Code",
|
||||
description: "Alibaba's Qwen coding assistant",
|
||||
version: "0.26.0",
|
||||
version: "0.20.1",
|
||||
iconId: "qwen-code",
|
||||
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.26.0", "--acp", "--experimental-skills"],
|
||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.20.1", "--acp", "--experimental-skills"],
|
||||
},
|
||||
{
|
||||
id: "sigit",
|
||||
|
||||
@@ -617,7 +617,7 @@ describe("computeSidebarOrderUpdates", () => {
|
||||
persistedProjectOrder: ["stale-project"],
|
||||
});
|
||||
|
||||
expect(updates).toEqual({ projectOrder: null, workspaceOrders: [] });
|
||||
expect(updates).toEqual({ projectOrder: null });
|
||||
});
|
||||
|
||||
it("appends unseen projects without changing saved project order", () => {
|
||||
@@ -979,20 +979,3 @@ it("retains unrelated projects and keyed row placements when one project reorder
|
||||
expect(after.projects[0].workspaces[0]).toBe(before.projects[0].workspaces[1]);
|
||||
expect(after.projects[0].workspaces[1]).toBe(before.projects[0].workspaces[0]);
|
||||
});
|
||||
|
||||
it("prepends newly discovered manual workspaces without dropping filtered saved keys", () => {
|
||||
const projects = [sidebarProject({ projectKey: "p", workspaceKeys: ["new", "b", "a"] })];
|
||||
expect(
|
||||
computeSidebarOrderUpdates({
|
||||
projects,
|
||||
persistedProjectOrder: ["p"],
|
||||
getWorkspaceOrder: () => ["srv:hidden", "srv:a", "srv:b"],
|
||||
}),
|
||||
).toEqual({
|
||||
projectOrder: null,
|
||||
workspaceOrders: [{ projectViewKey: "p", order: ["srv:new", "srv:hidden", "srv:a", "srv:b"] }],
|
||||
});
|
||||
expect(
|
||||
computeSidebarOrderUpdates({ projects, persistedProjectOrder: ["p"] }).workspaceOrders,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -535,57 +535,21 @@ export function appendMissingOrderKeys(input: {
|
||||
return [...input.currentOrder, ...missingKeys];
|
||||
}
|
||||
|
||||
export function prependMissingOrderKeys(input: {
|
||||
currentOrder: string[];
|
||||
visibleKeys: string[];
|
||||
}): string[] {
|
||||
if (input.visibleKeys.length === 0) {
|
||||
return input.currentOrder;
|
||||
}
|
||||
|
||||
const existingKeys = new Set(input.currentOrder);
|
||||
const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key));
|
||||
if (missingKeys.length === 0) {
|
||||
return input.currentOrder;
|
||||
}
|
||||
|
||||
return [...missingKeys, ...input.currentOrder];
|
||||
}
|
||||
|
||||
export interface SidebarOrderUpdates {
|
||||
projectOrder: string[] | null;
|
||||
workspaceOrders: Array<{ projectViewKey: string; order: string[] }>;
|
||||
}
|
||||
|
||||
export function computeSidebarOrderUpdates(input: {
|
||||
projects: SidebarProjectEntry[];
|
||||
persistedProjectOrder: string[];
|
||||
getWorkspaceOrder?: (projectViewKey: string) => string[];
|
||||
}): SidebarOrderUpdates {
|
||||
if (input.projects.length === 0) {
|
||||
return { projectOrder: null, workspaceOrders: [] };
|
||||
}
|
||||
|
||||
const nextProjectOrder = appendMissingOrderKeys({
|
||||
currentOrder: input.persistedProjectOrder,
|
||||
visibleKeys: input.projects.map((project) => project.viewKey),
|
||||
});
|
||||
const projectOrder = nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder;
|
||||
|
||||
const workspaceOrders: Array<{ projectViewKey: string; order: string[] }> = [];
|
||||
for (const project of input.projects) {
|
||||
if (!input.getWorkspaceOrder) break;
|
||||
const persistedWorkspaceOrder = input.getWorkspaceOrder(project.viewKey);
|
||||
const nextWorkspaceOrder = prependMissingOrderKeys({
|
||||
currentOrder: persistedWorkspaceOrder,
|
||||
visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
|
||||
});
|
||||
if (nextWorkspaceOrder !== persistedWorkspaceOrder) {
|
||||
workspaceOrders.push({ projectViewKey: project.viewKey, order: nextWorkspaceOrder });
|
||||
}
|
||||
}
|
||||
|
||||
return { projectOrder, workspaceOrders };
|
||||
return {
|
||||
projectOrder: nextProjectOrder === input.persistedProjectOrder ? null : nextProjectOrder,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SidebarLoadingState {
|
||||
|
||||
@@ -61,8 +61,8 @@ describe("ACP provider catalog", () => {
|
||||
it("offers MiniMax Code through its pinned public ACP package", () => {
|
||||
expect(findProvider("minimax-code")).toMatchObject({
|
||||
title: "MiniMax Code",
|
||||
version: "0.6.13",
|
||||
command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"],
|
||||
version: "0.1.2",
|
||||
command: ["npx", "-y", "@minimax-ai/code@0.1.2", "acp"],
|
||||
});
|
||||
expect(findProvider("minimax-code").iconSvg).toContain("<svg");
|
||||
});
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
saveAppSettings as saveAppSettingsPure,
|
||||
type AppSettings,
|
||||
type AppSettingsUpdate,
|
||||
type ChatDisplay,
|
||||
type OpenInSidePanePreferences,
|
||||
type PullRequestOpenLocation,
|
||||
type DesktopSettingsBridge,
|
||||
@@ -85,7 +84,6 @@ export {
|
||||
export type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
ChatDisplay,
|
||||
AppLanguage,
|
||||
OpenInSidePanePreferences,
|
||||
PullRequestOpenLocation,
|
||||
|
||||
@@ -185,7 +185,6 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
|
||||
expect(result).toEqual(DEFAULT_CLIENT_SETTINGS);
|
||||
expect(DEFAULT_CLIENT_SETTINGS.language).toBe("system");
|
||||
expect(DEFAULT_CLIENT_SETTINGS.chatDisplay).toBe("collapseTurns");
|
||||
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual(
|
||||
DEFAULT_CLIENT_SETTINGS,
|
||||
);
|
||||
@@ -455,7 +454,6 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
theme: "dark",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
chatDisplay: "full",
|
||||
});
|
||||
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual({
|
||||
manageBuiltInDaemon: false,
|
||||
@@ -594,7 +592,6 @@ describe("loadSettingsFromStorage", () => {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
theme: "light",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
chatDisplay: "full",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -639,7 +636,6 @@ describe("loadSettingsFromStorage", () => {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
theme: "light",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
chatDisplay: "full",
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
@@ -661,7 +657,6 @@ describe("loadSettingsFromStorage", () => {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
theme: "light",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
chatDisplay: "full",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -737,7 +732,7 @@ describe("saveAppSettings", () => {
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
theme: "light",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
chatDisplay: "collapseToolCalls",
|
||||
toolCallDetailLevel: "overview",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -792,45 +787,27 @@ describe("appearance settings", () => {
|
||||
expect(result.contentFontSize).toBe(DEFAULT_UI_BASE_FONT_SIZE);
|
||||
expect(result.codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE);
|
||||
expect(result.syntaxTheme).toBe("one");
|
||||
expect(result.chatDisplay).toBe("full");
|
||||
expect(result.toolCallDetailLevel).toBe("detailed");
|
||||
});
|
||||
|
||||
it("migrates the enabled compact tool call preference to collapsed tool calls", async () => {
|
||||
it("migrates the enabled compact tool call preference to overview", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ compactToolCalls: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe("collapseToolCalls");
|
||||
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["overview", "collapseToolCalls"],
|
||||
["concise", "collapseToolCalls"],
|
||||
["detailed", "full"],
|
||||
["unknown", "full"],
|
||||
])("reads the %s tool call detail level as the %s chat display", async (legacy, display) => {
|
||||
it("clears settings with an unrecognized tool call detail level", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: legacy }),
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "unknown" }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe(display);
|
||||
});
|
||||
|
||||
it("keeps a stored chat display over the tool call detail level it replaced", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({
|
||||
chatDisplay: "collapseTurns",
|
||||
toolCallDetailLevel: "detailed",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe("collapseTurns");
|
||||
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("detailed");
|
||||
});
|
||||
|
||||
it("migrates a switched-off checks row item to the hidden checks display", async () => {
|
||||
|
||||
@@ -40,8 +40,7 @@ export type WorkspaceTitleSource = "title" | "branch";
|
||||
export type PullRequestOpenLocation = "main" | "side" | "explorer";
|
||||
/** What a sidebar workspace row shows in the space to the right of its title. */
|
||||
export type SidebarWorkspaceTrailing = "diff" | "timestamp" | "none";
|
||||
/** How much of an agent's work the chat shows between the user's message and the reply. */
|
||||
export type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns";
|
||||
export type ToolCallDetailLevel = "overview" | "detailed";
|
||||
|
||||
const ThemePreferenceSchema = z.enum([
|
||||
...THEME_OPTIONS.map((option) => option.name),
|
||||
@@ -101,7 +100,7 @@ export interface AppSettings {
|
||||
/** How usage reads and which windows the sidebar summary shows. */
|
||||
usage: UsagePreferences;
|
||||
autoExpandReasoning: boolean;
|
||||
chatDisplay: ChatDisplay;
|
||||
toolCallDetailLevel: ToolCallDetailLevel;
|
||||
chatOutlineEnabled: boolean;
|
||||
vimKeybindings: boolean;
|
||||
filePanelAssociations: Record<string, string>;
|
||||
@@ -157,7 +156,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
sidebarFooterItems: [],
|
||||
usage: DEFAULT_USAGE_PREFERENCES,
|
||||
autoExpandReasoning: false,
|
||||
chatDisplay: "collapseTurns",
|
||||
toolCallDetailLevel: "detailed",
|
||||
chatOutlineEnabled: true,
|
||||
vimKeybindings: false,
|
||||
filePanelAssociations: {},
|
||||
@@ -200,7 +199,6 @@ const SidebarRowItemsSchema = z
|
||||
type StoredAppSettingsFallback = AppSettings & {
|
||||
uiFontSize?: number;
|
||||
compactToolCalls?: boolean;
|
||||
toolCallDetailLevel?: "overview" | "detailed";
|
||||
manageBuiltInDaemon?: boolean;
|
||||
releaseChannel?: ReleaseChannel;
|
||||
needsWrite: boolean;
|
||||
@@ -253,13 +251,11 @@ const StoredAppSettingsSchema = z
|
||||
sidebarFooterItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
|
||||
usage: UsagePreferencesSchema,
|
||||
autoExpandReasoning: z.boolean().catch(false),
|
||||
chatDisplay: z.enum(["full", "collapseToolCalls", "collapseTurns"]).optional().catch("full"),
|
||||
// COMPAT(toolCallDetailLevel): replaced by chatDisplay in v0.13, remove after 2027-04-24.
|
||||
toolCallDetailLevel: z
|
||||
.enum(["overview", "detailed"])
|
||||
.or(z.literal("concise").transform(() => "overview" as const))
|
||||
.optional()
|
||||
.catch(undefined),
|
||||
.catch("detailed"),
|
||||
// COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12.
|
||||
compactToolCalls: z.boolean().optional().catch(undefined),
|
||||
chatOutlineEnabled: z.boolean().catch(true),
|
||||
@@ -309,10 +305,8 @@ const StoredAppSettingsSchema = z
|
||||
(isChecksHiddenByLegacyRowItem(stored.sidebarRowItems)
|
||||
? "none"
|
||||
: DEFAULT_SIDEBAR_CHECKS_DISPLAY);
|
||||
const groupsToolCalls = stored.toolCallDetailLevel
|
||||
? stored.toolCallDetailLevel === "overview"
|
||||
: stored.compactToolCalls === true;
|
||||
const chatDisplay = stored.chatDisplay ?? (groupsToolCalls ? "collapseToolCalls" : "full");
|
||||
const toolCallDetailLevel =
|
||||
stored.toolCallDetailLevel ?? (stored.compactToolCalls ? "overview" : "detailed");
|
||||
return {
|
||||
...stored,
|
||||
openInSidePane,
|
||||
@@ -327,7 +321,7 @@ const StoredAppSettingsSchema = z
|
||||
stored.sidebarRowItems.services ??
|
||||
(stored.sidebarRowItems.scripts === false ? false : DEFAULT_SIDEBAR_ROW_ITEMS.services),
|
||||
},
|
||||
chatDisplay,
|
||||
toolCallDetailLevel,
|
||||
needsWrite,
|
||||
};
|
||||
})
|
||||
@@ -455,7 +449,6 @@ export function normalizeAppSettings(value: unknown): AppSettings {
|
||||
manageBuiltInDaemon: _manageBuiltInDaemon,
|
||||
releaseChannel: _releaseChannel,
|
||||
compactToolCalls: _compactToolCalls,
|
||||
toolCallDetailLevel: _toolCallDetailLevel,
|
||||
uiFontSize: _uiFontSize,
|
||||
...settings
|
||||
} = StoredAppSettingsSchema.parse(value);
|
||||
|
||||
@@ -144,10 +144,7 @@ export function useSidebarWorkspacesList(options?: {
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
const sortSupport = useHostFeatureMap(directoryServerIds, "workspaceSortTimes");
|
||||
const workspaceSortSupported = [...sortSupport.values()].every(Boolean);
|
||||
const sort =
|
||||
groupMode === "project" && (workspaceSort === "manual" || workspaceSortSupported)
|
||||
? workspaceSort
|
||||
: undefined;
|
||||
const sort = workspaceSortSupported && groupMode === "project" ? workspaceSort : undefined;
|
||||
const { projects: hostProjects } = useWorkspaceStructure(directoryServerIds, sort);
|
||||
|
||||
const selectPlacements = useMemo(createSidebarWorkspacePlacementSelector, []);
|
||||
@@ -169,16 +166,12 @@ export function useSidebarWorkspacesList(options?: {
|
||||
const updates = computeSidebarOrderUpdates({
|
||||
projects,
|
||||
persistedProjectOrder,
|
||||
getWorkspaceOrder: sort === "manual" ? orderStore.getWorkspaceOrder : undefined,
|
||||
});
|
||||
|
||||
if (updates.projectOrder) {
|
||||
orderStore.setProjectOrder(updates.projectOrder);
|
||||
}
|
||||
for (const update of updates.workspaceOrders) {
|
||||
orderStore.setWorkspaceOrder(update.projectViewKey, update.order);
|
||||
}
|
||||
}, [persistedProjectOrder, projects, sort]);
|
||||
}, [persistedProjectOrder, projects]);
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
if (!isActive) return;
|
||||
|
||||
@@ -67,6 +67,7 @@ export const ar: TranslationResources = {
|
||||
daemonClientUnavailable: "عميل Daemon غير متوفر",
|
||||
daemonClientDisconnected: "تم قطع اتصال عميل Daemon",
|
||||
noFileFound: "لم يتم العثور على ملف لـ{{token}}",
|
||||
folderOutsideWorkspace: "{{path}} خارج مساحة العمل هذه",
|
||||
unexpectedDictationError: "حدث خطأ غير متوقع أثناء معالجة الإملاء.",
|
||||
},
|
||||
connectionStatus: {
|
||||
@@ -1169,7 +1170,6 @@ export const ar: TranslationResources = {
|
||||
sort: {
|
||||
label: "الترتيب",
|
||||
created: "تاريخ الإنشاء",
|
||||
manual: "يدوي",
|
||||
lastPrompt: "آخر طلب",
|
||||
updateHost: "حدّث المضيفين لترتيب مساحات العمل",
|
||||
},
|
||||
@@ -1908,13 +1908,6 @@ export const ar: TranslationResources = {
|
||||
backdrop: "خلفية القائمة",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "العودة إلى الوكيل الأصلي",
|
||||
backTo: "العودة إلى {{label}}",
|
||||
parent: "الأصل",
|
||||
parentAgent: "الوكيل الأصلي",
|
||||
openParent: "فتح الوكيل الأصلي",
|
||||
},
|
||||
title: "الوكلاء الفرعيون",
|
||||
pillLabelOne: "وكيل فرعي واحد",
|
||||
pillLabelMany: "{{count}} وكلاء فرعيين",
|
||||
@@ -2015,12 +2008,6 @@ export const ar: TranslationResources = {
|
||||
input: "مدخل",
|
||||
output: "الإخراج",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "كتب {{count}} رسالة",
|
||||
other: "كتب {{count}} رسائل",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "حرّر {{count}} ملفًا",
|
||||
@@ -2325,13 +2312,12 @@ export const ar: TranslationResources = {
|
||||
label: "عرض التفكير دائماً",
|
||||
description: "إظهار تفكير الوكيل وخطوات الاستدلال بشكل كامل بشكل افتراضي",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "عرض المحادثة",
|
||||
description: "مقدار عمل الوكيل الذي تعرضه المحادثة",
|
||||
toolCallDetail: {
|
||||
label: "عرض استدعاءات الأدوات",
|
||||
description: "كيفية ظهور استدعاءات الأدوات في المخطط الزمني",
|
||||
options: {
|
||||
full: "كامل",
|
||||
collapseToolCalls: "طيّ استدعاءات الأدوات",
|
||||
collapseTurns: "طيّ الأدوار",
|
||||
overview: "ملخص",
|
||||
detailed: "التفاصيل الكاملة",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
@@ -2698,11 +2684,6 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "Connect to this host to manage agents",
|
||||
idleRuntimeCleanup: {
|
||||
title: "تحرير الوكلاء الخاملين",
|
||||
hint: "تحرير الذاكرة بعد ساعة من الخمول.",
|
||||
info: "يوقف عملية الوكيل مع الاحتفاظ بمحادثته. أرسل رسالة لاستئنافها.\n\nيمنع العمل النشط والإشعارات المعلّقة وفحوصات أمان المزوّد عملية التنظيف.",
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
unavailable: "Connect to this host to manage workspaces",
|
||||
|
||||
@@ -63,6 +63,7 @@ export const en = {
|
||||
daemonClientUnavailable: "Daemon client unavailable",
|
||||
daemonClientDisconnected: "Daemon client is disconnected",
|
||||
noFileFound: "No file found for {{token}}",
|
||||
folderOutsideWorkspace: "{{path}} is outside this workspace",
|
||||
unexpectedDictationError: "An unexpected error occurred while handling dictation.",
|
||||
},
|
||||
connectionStatus: {
|
||||
@@ -1177,7 +1178,6 @@ export const en = {
|
||||
sort: {
|
||||
label: "Sort",
|
||||
created: "Created",
|
||||
manual: "Manual",
|
||||
lastPrompt: "Last prompt",
|
||||
updateHost: "Update hosts to sort workspaces",
|
||||
},
|
||||
@@ -1933,13 +1933,6 @@ export const en = {
|
||||
backdrop: "Menu backdrop",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Back to parent",
|
||||
backTo: "Back to {{label}}",
|
||||
parent: "Parent",
|
||||
parentAgent: "Parent agent",
|
||||
openParent: "Open parent",
|
||||
},
|
||||
title: "Subagents",
|
||||
pillLabelOne: "1 subagent",
|
||||
pillLabelMany: "{{count}} subagents",
|
||||
@@ -2040,12 +2033,6 @@ export const en = {
|
||||
input: "Input",
|
||||
output: "Output",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "wrote {{count}} message",
|
||||
other: "wrote {{count}} messages",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "edited {{count}} file",
|
||||
@@ -2413,13 +2400,12 @@ export const en = {
|
||||
label: "Always expand reasoning",
|
||||
description: "Show agent thinking and chain-of-thought blocks fully expanded by default",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "Chat display",
|
||||
description: "How much of an agent's work the chat shows",
|
||||
toolCallDetail: {
|
||||
label: "Tool call display",
|
||||
description: "How tool calls appear in the timeline",
|
||||
options: {
|
||||
full: "Full",
|
||||
collapseToolCalls: "Collapse tool calls",
|
||||
collapseTurns: "Collapse turns",
|
||||
overview: "Summary",
|
||||
detailed: "Full detail",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
@@ -2789,11 +2775,6 @@ export const en = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "Connect to this host to manage agents",
|
||||
idleRuntimeCleanup: {
|
||||
title: "Release idle agents",
|
||||
hint: "Free up memory after an hour of inactivity.",
|
||||
info: "Stops the agent's process while keeping its conversation. Send a message to resume.\n\nActive work, pending notifications, and provider safety checks prevent cleanup.",
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
unavailable: "Connect to this host to manage workspaces",
|
||||
|
||||
@@ -68,6 +68,7 @@ export const es: TranslationResources = {
|
||||
daemonClientUnavailable: "ClienteDaemonno disponible",
|
||||
daemonClientDisconnected: "El clienteDaemonestá desconectado",
|
||||
noFileFound: "No se encontró ningún archivo para{{token}}",
|
||||
folderOutsideWorkspace: "{{path}} está fuera de este espacio de trabajo",
|
||||
unexpectedDictationError: "Se produjo un error inesperado al manejar el dictado.",
|
||||
},
|
||||
connectionStatus: {
|
||||
@@ -1205,7 +1206,6 @@ export const es: TranslationResources = {
|
||||
sort: {
|
||||
label: "Orden",
|
||||
created: "Creación",
|
||||
manual: "Manual",
|
||||
lastPrompt: "Último prompt",
|
||||
updateHost: "Actualiza los hosts para ordenar los espacios de trabajo",
|
||||
},
|
||||
@@ -1956,13 +1956,6 @@ export const es: TranslationResources = {
|
||||
backdrop: "Fondo del menú",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Volver al padre",
|
||||
backTo: "Volver a {{label}}",
|
||||
parent: "Padre",
|
||||
parentAgent: "Agente padre",
|
||||
openParent: "Abrir padre",
|
||||
},
|
||||
title: "Subagentes",
|
||||
pillLabelOne: "1 subagente",
|
||||
pillLabelMany: "{{count}} subagentes",
|
||||
@@ -2063,12 +2056,6 @@ export const es: TranslationResources = {
|
||||
input: "Aporte",
|
||||
output: "Producción",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "escribió {{count}} mensaje",
|
||||
other: "escribió {{count}} mensajes",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "editó {{count}} archivo",
|
||||
@@ -2341,13 +2328,12 @@ export const es: TranslationResources = {
|
||||
description:
|
||||
"Mostrar los bloques de pensamiento y razonamiento del agente totalmente expandidos de forma predeterminada",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "Visualización del chat",
|
||||
description: "Cuánto del trabajo del agente muestra el chat",
|
||||
toolCallDetail: {
|
||||
label: "Visualización de llamadas a herramientas",
|
||||
description: "Cómo aparecen las llamadas a herramientas en la cronología",
|
||||
options: {
|
||||
full: "Completo",
|
||||
collapseToolCalls: "Contraer llamadas a herramientas",
|
||||
collapseTurns: "Contraer turnos",
|
||||
overview: "Resumen",
|
||||
detailed: "Detalle completo",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
@@ -2718,11 +2704,6 @@ export const es: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "Connect to this host to manage agents",
|
||||
idleRuntimeCleanup: {
|
||||
title: "Liberar agentes inactivos",
|
||||
hint: "Libera memoria tras una hora de inactividad.",
|
||||
info: "Detiene el proceso del agente y conserva la conversación. Envía un mensaje para reanudarla.\n\nEl trabajo activo, las notificaciones pendientes y las comprobaciones de seguridad del proveedor impiden la limpieza.",
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
unavailable: "Connect to this host to manage workspaces",
|
||||
|
||||
@@ -68,6 +68,7 @@ export const fr: TranslationResources = {
|
||||
daemonClientUnavailable: "Client du daemon indisponible",
|
||||
daemonClientDisconnected: "Le client du daemon est déconnecté",
|
||||
noFileFound: "Aucun fichier trouvé pour {{token}}",
|
||||
folderOutsideWorkspace: "{{path}} se trouve en dehors de cet espace de travail",
|
||||
unexpectedDictationError: "Une erreur inattendue s’est produite pendant la dictée.",
|
||||
},
|
||||
connectionStatus: {
|
||||
@@ -1198,7 +1199,6 @@ export const fr: TranslationResources = {
|
||||
sort: {
|
||||
label: "Tri",
|
||||
created: "Création",
|
||||
manual: "Manuel",
|
||||
lastPrompt: "Dernier prompt",
|
||||
updateHost: "Mettez les hôtes à jour pour trier les espaces de travail",
|
||||
},
|
||||
@@ -1954,13 +1954,6 @@ export const fr: TranslationResources = {
|
||||
backdrop: "Arrière-plan du menu",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Retour au parent",
|
||||
backTo: "Retour à {{label}}",
|
||||
parent: "Parent",
|
||||
parentAgent: "Agent parent",
|
||||
openParent: "Ouvrir le parent",
|
||||
},
|
||||
title: "Sous-agents",
|
||||
pillLabelOne: "1 sous-agent",
|
||||
pillLabelMany: "{{count}} sous-agents",
|
||||
@@ -2062,12 +2055,6 @@ export const fr: TranslationResources = {
|
||||
input: "Entrée",
|
||||
output: "Sortie",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "{{count}} message écrit",
|
||||
other: "{{count}} messages écrits",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "{{count}} fichier modifié",
|
||||
@@ -2339,13 +2326,12 @@ export const fr: TranslationResources = {
|
||||
label: "Toujours afficher le raisonnement",
|
||||
description: "Afficher par défaut le raisonnement de l’agent entièrement déplié",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "Affichage du chat",
|
||||
description: "La part du travail de l’agent affichée dans le chat",
|
||||
toolCallDetail: {
|
||||
label: "Affichage des appels d’outils",
|
||||
description: "Comment les appels d’outils apparaissent dans la chronologie",
|
||||
options: {
|
||||
full: "Complet",
|
||||
collapseToolCalls: "Réduire les appels d’outils",
|
||||
collapseTurns: "Réduire les tours",
|
||||
overview: "Résumé",
|
||||
detailed: "Détails complets",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
@@ -2721,11 +2707,6 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "Connectez-vous à cet hôte pour gérer les agents",
|
||||
idleRuntimeCleanup: {
|
||||
title: "Libérer les agents inactifs",
|
||||
hint: "Libère de la mémoire après une heure d’inactivité.",
|
||||
info: "Arrête le processus de l’agent tout en conservant sa conversation. Envoyez un message pour la reprendre.\n\nLe travail en cours, les notifications en attente et les vérifications de sécurité du fournisseur empêchent le nettoyage.",
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
unavailable: "Connectez-vous à cet hôte pour gérer les espaces de travail",
|
||||
|
||||
@@ -68,6 +68,7 @@ export const ja: TranslationResources = {
|
||||
daemonClientUnavailable: "デーモンクライアントが利用できません",
|
||||
daemonClientDisconnected: "デーモンクライアントが切断されています",
|
||||
noFileFound: "{{token}}のファイルが見つかりません",
|
||||
folderOutsideWorkspace: "{{path}} はこのワークスペースの外にあります",
|
||||
unexpectedDictationError: "音声入力処理中に予期しないエラーが発生しました。",
|
||||
},
|
||||
connectionStatus: {
|
||||
@@ -1182,7 +1183,6 @@ export const ja: TranslationResources = {
|
||||
sort: {
|
||||
label: "並び順",
|
||||
created: "作成日時",
|
||||
manual: "手動",
|
||||
lastPrompt: "最後のプロンプト",
|
||||
updateHost: "ワークスペースを並べ替えるにはホストを更新してください",
|
||||
},
|
||||
@@ -1927,13 +1927,6 @@ export const ja: TranslationResources = {
|
||||
backdrop: "メニューの背景",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "親に戻る",
|
||||
backTo: "{{label}} に戻る",
|
||||
parent: "親",
|
||||
parentAgent: "親エージェント",
|
||||
openParent: "親を開く",
|
||||
},
|
||||
title: "サブエージェント",
|
||||
pillLabelOne: "サブエージェント 1 件",
|
||||
pillLabelMany: "サブエージェント {{count}} 件",
|
||||
@@ -2035,12 +2028,6 @@ export const ja: TranslationResources = {
|
||||
input: "入力",
|
||||
output: "出力",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "{{count}}件のメッセージを送信",
|
||||
other: "{{count}}件のメッセージを送信",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "{{count}}個のファイルを編集",
|
||||
@@ -2309,13 +2296,12 @@ export const ja: TranslationResources = {
|
||||
label: "常に思考プロセスを展開",
|
||||
description: "デフォルトでAIのエージェント思考・推論ブロックを完全に展開して表示します",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "チャットの表示",
|
||||
description: "チャットに表示するエージェントの作業量",
|
||||
toolCallDetail: {
|
||||
label: "ツール呼び出しの表示",
|
||||
description: "タイムラインでのツール呼び出しの表示方法",
|
||||
options: {
|
||||
full: "すべて",
|
||||
collapseToolCalls: "ツール呼び出しを折りたたむ",
|
||||
collapseTurns: "ターンを折りたたむ",
|
||||
overview: "要約",
|
||||
detailed: "すべての詳細",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
@@ -2687,11 +2673,6 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "エージェントを管理するにはこのホストに接続してください",
|
||||
idleRuntimeCleanup: {
|
||||
title: "アイドル状態のエージェントを解放",
|
||||
hint: "1 時間のアイドル状態の後にメモリを解放します。",
|
||||
info: "会話を保持したままエージェントのプロセスを停止します。メッセージを送信すると再開します。\n\n実行中の作業、保留中の通知、プロバイダーの安全性チェックがある場合は解放しません。",
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
unavailable: "ワークスペースを管理するにはこのホストに接続してください",
|
||||
|
||||
@@ -67,6 +67,7 @@ export const ko: TranslationResources = {
|
||||
daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다",
|
||||
daemonClientDisconnected: "데몬 클라이언트 연결이 끊어졌습니다",
|
||||
noFileFound: "{{token}}에 해당하는 파일을 찾을 수 없습니다",
|
||||
folderOutsideWorkspace: "{{path}}은(는) 이 워크스페이스 밖에 있습니다",
|
||||
unexpectedDictationError: "받아쓰기를 처리하는 중 예기치 않은 오류가 발생했습니다.",
|
||||
},
|
||||
connectionStatus: {
|
||||
@@ -1176,7 +1177,6 @@ export const ko: TranslationResources = {
|
||||
sort: {
|
||||
label: "정렬",
|
||||
created: "생성일",
|
||||
manual: "수동",
|
||||
lastPrompt: "마지막 프롬프트",
|
||||
updateHost: "작업 공간을 정렬하려면 호스트를 업데이트하세요",
|
||||
},
|
||||
@@ -1918,13 +1918,6 @@ export const ko: TranslationResources = {
|
||||
backdrop: "메뉴 배경",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "상위 에이전트로 돌아가기",
|
||||
backTo: "{{label}}(으)로 돌아가기",
|
||||
parent: "상위",
|
||||
parentAgent: "상위 에이전트",
|
||||
openParent: "상위 에이전트 열기",
|
||||
},
|
||||
title: "하위 에이전트",
|
||||
pillLabelOne: "하위 에이전트 1개",
|
||||
pillLabelMany: "하위 에이전트 {{count}}개",
|
||||
@@ -2025,12 +2018,6 @@ export const ko: TranslationResources = {
|
||||
input: "입력",
|
||||
output: "출력",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "{{count}} 메시지를 작성했습니다.",
|
||||
other: "{{count}} 메시지를 작성했습니다.",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "{{count}} 파일을 편집했습니다.",
|
||||
@@ -2300,13 +2287,12 @@ export const ko: TranslationResources = {
|
||||
label: "추론 항상 펼치기",
|
||||
description: "에이전트의 사고 및 추론 블록을 기본적으로 모두 펼쳐 표시합니다.",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "채팅 표시",
|
||||
description: "채팅에 표시할 에이전트 작업의 양",
|
||||
toolCallDetail: {
|
||||
label: "도구 호출 표시",
|
||||
description: "타임라인에 도구 호출이 표시되는 방식",
|
||||
options: {
|
||||
full: "전체",
|
||||
collapseToolCalls: "도구 호출 접기",
|
||||
collapseTurns: "턴 접기",
|
||||
overview: "요약",
|
||||
detailed: "전체 세부정보",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
@@ -2673,11 +2659,6 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "에이전트를 관리하려면 이 호스트에 연결하세요",
|
||||
idleRuntimeCleanup: {
|
||||
title: "유휴 에이전트 해제",
|
||||
hint: "1시간 동안 활동이 없으면 메모리를 확보합니다.",
|
||||
info: "대화를 유지하면서 에이전트 프로세스를 중지합니다. 메시지를 보내면 다시 이어집니다.\n\n진행 중인 작업, 대기 중인 알림, 프로바이더 안전 검사가 있으면 정리하지 않습니다.",
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
unavailable: "워크스페이스를 관리하려면 이 호스트에 연결하세요",
|
||||
|
||||
@@ -68,6 +68,7 @@ export const ptBR: TranslationResources = {
|
||||
daemonClientUnavailable: "Cliente do daemon indisponível",
|
||||
daemonClientDisconnected: "O cliente do daemon está desconectado",
|
||||
noFileFound: "Nenhum arquivo encontrado para {{token}}",
|
||||
folderOutsideWorkspace: "{{path}} está fora deste workspace",
|
||||
unexpectedDictationError: "Ocorreu um erro inesperado ao processar o ditado.",
|
||||
},
|
||||
connectionStatus: {
|
||||
@@ -1195,7 +1196,6 @@ export const ptBR: TranslationResources = {
|
||||
sort: {
|
||||
label: "Ordenação",
|
||||
created: "Criação",
|
||||
manual: "Manual",
|
||||
lastPrompt: "Último prompt",
|
||||
updateHost: "Atualize os hosts para ordenar os espaços de trabalho",
|
||||
},
|
||||
@@ -1941,13 +1941,6 @@ export const ptBR: TranslationResources = {
|
||||
backdrop: "Fundo do menu",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Voltar ao pai",
|
||||
backTo: "Voltar para {{label}}",
|
||||
parent: "Pai",
|
||||
parentAgent: "Agente pai",
|
||||
openParent: "Abrir pai",
|
||||
},
|
||||
title: "Subagentes",
|
||||
pillLabelOne: "1 subagente",
|
||||
pillLabelMany: "{{count}} subagentes",
|
||||
@@ -2048,12 +2041,6 @@ export const ptBR: TranslationResources = {
|
||||
input: "Entrada",
|
||||
output: "Saída",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "escreveu {{count}} mensagem",
|
||||
other: "escreveu {{count}} mensagens",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "editou {{count}} arquivo",
|
||||
@@ -2324,13 +2311,12 @@ export const ptBR: TranslationResources = {
|
||||
description:
|
||||
"Mostrar os blocos de pensamento e raciocínio do agente totalmente expandidos por padrão",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "Exibição do chat",
|
||||
description: "Quanto do trabalho do agente o chat mostra",
|
||||
toolCallDetail: {
|
||||
label: "Exibição de chamadas de ferramentas",
|
||||
description: "Como as chamadas de ferramentas aparecem na linha do tempo",
|
||||
options: {
|
||||
full: "Completo",
|
||||
collapseToolCalls: "Recolher chamadas de ferramentas",
|
||||
collapseTurns: "Recolher turnos",
|
||||
overview: "Resumo",
|
||||
detailed: "Detalhes completos",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
@@ -2700,11 +2686,6 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "Conecte-se a este host para gerenciar agentes",
|
||||
idleRuntimeCleanup: {
|
||||
title: "Liberar agentes ociosos",
|
||||
hint: "Libera memória após uma hora de inatividade.",
|
||||
info: "Encerra o processo do agente e mantém a conversa. Envie uma mensagem para retomar.\n\nTrabalho ativo, notificações pendentes e verificações de segurança do provedor impedem a limpeza.",
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
unavailable: "Conecte-se a este host para gerenciar workspaces",
|
||||
|
||||
@@ -68,6 +68,7 @@ export const ru: TranslationResources = {
|
||||
daemonClientUnavailable: "Daemon клиента недоступен",
|
||||
daemonClientDisconnected: "Daemon клиента отключен",
|
||||
noFileFound: "Файл для {{token}} не найден",
|
||||
folderOutsideWorkspace: "{{path}} находится вне этого рабочего пространства",
|
||||
unexpectedDictationError: "При обработке диктовки произошла непредвиденная ошибка.",
|
||||
},
|
||||
connectionStatus: {
|
||||
@@ -1186,7 +1187,6 @@ export const ru: TranslationResources = {
|
||||
sort: {
|
||||
label: "Сортировка",
|
||||
created: "Дата создания",
|
||||
manual: "Вручную",
|
||||
lastPrompt: "Последний запрос",
|
||||
updateHost: "Обновите хосты для сортировки рабочих пространств",
|
||||
},
|
||||
@@ -1940,13 +1940,6 @@ export const ru: TranslationResources = {
|
||||
backdrop: "Фон меню",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "Назад к родителю",
|
||||
backTo: "Назад к {{label}}",
|
||||
parent: "Родитель",
|
||||
parentAgent: "Родительский агент",
|
||||
openParent: "Открыть родителя",
|
||||
},
|
||||
title: "Субагенты",
|
||||
pillLabelOne: "1 субагент",
|
||||
pillLabelMany: "Субагентов: {{count}}",
|
||||
@@ -2048,12 +2041,6 @@ export const ru: TranslationResources = {
|
||||
input: "Входные данные",
|
||||
output: "Выходные данные",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "написано {{count}} сообщение",
|
||||
other: "написаны сообщения ({{count}})",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "изменён {{count}} файл",
|
||||
@@ -2326,13 +2313,12 @@ export const ru: TranslationResources = {
|
||||
description:
|
||||
"По умолчанию полностью разворачивать блоки размышлений и хода рассуждений агента",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "Отображение чата",
|
||||
description: "Какую часть работы агента показывает чат",
|
||||
toolCallDetail: {
|
||||
label: "Отображение вызовов инструментов",
|
||||
description: "Как вызовы инструментов отображаются на временной шкале",
|
||||
options: {
|
||||
full: "Полностью",
|
||||
collapseToolCalls: "Сворачивать вызовы инструментов",
|
||||
collapseTurns: "Сворачивать ходы",
|
||||
overview: "Сводка",
|
||||
detailed: "Все подробности",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
@@ -2709,11 +2695,6 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "Подключитесь к этому хосту, чтобы управлять агентами",
|
||||
idleRuntimeCleanup: {
|
||||
title: "Освобождать неактивных агентов",
|
||||
hint: "Освобождает память после часа бездействия.",
|
||||
info: "Останавливает процесс агента, сохраняя разговор. Отправьте сообщение, чтобы продолжить.\n\nАктивная работа, ожидающие уведомления и проверки безопасности провайдера предотвращают очистку.",
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
unavailable: "Подключитесь к этому хосту, чтобы управлять рабочими пространствами",
|
||||
|
||||
@@ -67,6 +67,7 @@ export const zhCN: TranslationResources = {
|
||||
daemonClientUnavailable: "Daemon client 不可用",
|
||||
daemonClientDisconnected: "Daemon client 已断开连接",
|
||||
noFileFound: "未找到 {{token}} 对应的文件",
|
||||
folderOutsideWorkspace: "{{path}} 不在此工作区内",
|
||||
unexpectedDictationError: "处理听写时发生意外错误。",
|
||||
},
|
||||
connectionStatus: {
|
||||
@@ -1161,7 +1162,6 @@ export const zhCN: TranslationResources = {
|
||||
sort: {
|
||||
label: "排序",
|
||||
created: "创建时间",
|
||||
manual: "手动",
|
||||
lastPrompt: "最近提示词",
|
||||
updateHost: "请更新主机以排序工作区",
|
||||
},
|
||||
@@ -1886,13 +1886,6 @@ export const zhCN: TranslationResources = {
|
||||
backdrop: "菜单背景",
|
||||
},
|
||||
subagents: {
|
||||
parent: {
|
||||
backToParent: "返回父代理",
|
||||
backTo: "返回 {{label}}",
|
||||
parent: "父代理",
|
||||
parentAgent: "父代理",
|
||||
openParent: "打开父代理",
|
||||
},
|
||||
title: "Subagent",
|
||||
pillLabelOne: "1 个 subagent",
|
||||
pillLabelMany: "{{count}} 个 subagent",
|
||||
@@ -1993,12 +1986,6 @@ export const zhCN: TranslationResources = {
|
||||
input: "输入",
|
||||
output: "输出",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "写了 {{count}} 条消息",
|
||||
other: "写了 {{count}} 条消息",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "编辑了 {{count}} 个文件",
|
||||
@@ -2264,13 +2251,12 @@ export const zhCN: TranslationResources = {
|
||||
label: "始终展开推理过程",
|
||||
description: "默认情况下完全展开 AI 的思考和推理过程",
|
||||
},
|
||||
chatDisplay: {
|
||||
label: "聊天显示",
|
||||
description: "聊天中显示多少智能体的工作",
|
||||
toolCallDetail: {
|
||||
label: "工具调用显示",
|
||||
description: "工具调用在时间线中的显示方式",
|
||||
options: {
|
||||
full: "完整",
|
||||
collapseToolCalls: "折叠工具调用",
|
||||
collapseTurns: "折叠轮次",
|
||||
overview: "摘要",
|
||||
detailed: "完整详情",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
@@ -2634,11 +2620,6 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
agents: {
|
||||
unavailable: "连接到这个 Host 以管理 Agent",
|
||||
idleRuntimeCleanup: {
|
||||
title: "释放空闲 Agent",
|
||||
hint: "空闲一小时后释放内存。",
|
||||
info: "停止 Agent 进程,但保留对话。发送消息即可继续。\n\n进行中的工作、待处理的通知和 Provider 安全检查会阻止清理。",
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
unavailable: "连接到这个 Host 以管理 Workspace",
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { usePathname, useRootNavigationState } from "expo-router";
|
||||
import { useStore } from "zustand";
|
||||
import {
|
||||
useIsLastWorkspaceSelectionHydrated,
|
||||
useLastWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { createSettingsSelection } from "./model";
|
||||
|
||||
const selection = createSettingsSelection();
|
||||
|
||||
// Mount once above the sibling Settings routes so replacements and Back share
|
||||
// one visit, and leaving Settings gives the next workspace entry a fresh default.
|
||||
export function useSettingsRouteEntry(): void {
|
||||
const pathname = usePathname();
|
||||
const navigationState = useRootNavigationState();
|
||||
const routeKey = navigationState?.routes[navigationState.index]?.key;
|
||||
const workspace = useLastWorkspaceSelection();
|
||||
const isWorkspaceHydrated = useIsLastWorkspaceSelectionHydrated();
|
||||
const workspaceServerId = workspace?.serverId ?? null;
|
||||
useLayoutEffect(() => {
|
||||
if (isWorkspaceHydrated && routeKey) {
|
||||
selection.enterRoute({ key: routeKey, pathname }, workspaceServerId);
|
||||
}
|
||||
}, [pathname, routeKey, workspaceServerId, isWorkspaceHydrated]);
|
||||
}
|
||||
|
||||
export function useSettingsHostSelection(): string | null {
|
||||
return useStore(selection.store, (state) => state.selectedServerId);
|
||||
}
|
||||
|
||||
export const selectSettingsHost = selection.selectHost;
|
||||
@@ -1,102 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createSettingsSelection } from "./model";
|
||||
|
||||
describe("Settings host selection", () => {
|
||||
it("keeps picker choices across sections, replacement and Back", () => {
|
||||
const settings = createSettingsSelection();
|
||||
settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host");
|
||||
expect(settings.store.getState().selectedServerId).toBe("workspace-host");
|
||||
settings.selectHost("chosen-host");
|
||||
settings.enterRoute(
|
||||
{
|
||||
key: "/settings/hosts/chosen-host/connections",
|
||||
pathname: "/settings/hosts/chosen-host/connections",
|
||||
},
|
||||
"workspace-host",
|
||||
);
|
||||
settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host");
|
||||
settings.enterRoute(
|
||||
{ key: "/settings/appearance", pathname: "/settings/appearance" },
|
||||
"workspace-host",
|
||||
);
|
||||
settings.enterRoute(
|
||||
{ key: "/settings/general", pathname: "/settings/general" },
|
||||
"workspace-host",
|
||||
);
|
||||
expect(settings.store.getState().selectedServerId).toBe("chosen-host");
|
||||
});
|
||||
|
||||
it("applies explicit host links once, including project and plugin screens", () => {
|
||||
const settings = createSettingsSelection();
|
||||
settings.enterRoute(
|
||||
{
|
||||
key: "/settings/hosts/linked-host/projects/project",
|
||||
pathname: "/settings/hosts/linked-host/projects/project",
|
||||
},
|
||||
"workspace-host",
|
||||
);
|
||||
expect(settings.store.getState().selectedServerId).toBe("linked-host");
|
||||
settings.selectHost("chosen-host");
|
||||
settings.enterRoute(
|
||||
{
|
||||
key: "/settings/hosts/linked-host/projects/project",
|
||||
pathname: "/settings/hosts/linked-host/projects/project",
|
||||
},
|
||||
"workspace-host",
|
||||
);
|
||||
expect(settings.store.getState().selectedServerId).toBe("chosen-host");
|
||||
settings.enterRoute(
|
||||
{
|
||||
key: "/settings/hosts/plugin-host/plugins/plugin/screen",
|
||||
pathname: "/settings/hosts/plugin-host/plugins/plugin/screen",
|
||||
},
|
||||
"workspace-host",
|
||||
);
|
||||
expect(settings.store.getState().selectedServerId).toBe("plugin-host");
|
||||
});
|
||||
|
||||
it("seeds each new visit from the workspace, including a retained settings route", () => {
|
||||
const settings = createSettingsSelection();
|
||||
settings.enterRoute(
|
||||
{ key: "/settings/general", pathname: "/settings/general" },
|
||||
"first-workspace-host",
|
||||
);
|
||||
settings.selectHost("chosen-host");
|
||||
settings.enterRoute(
|
||||
{
|
||||
key: "/h/second-workspace-host/workspace/workspace",
|
||||
pathname: "/h/second-workspace-host/workspace/workspace",
|
||||
},
|
||||
"second-workspace-host",
|
||||
);
|
||||
settings.enterRoute(
|
||||
{ key: "/settings/general", pathname: "/settings/general" },
|
||||
"second-workspace-host",
|
||||
);
|
||||
expect(settings.store.getState().selectedServerId).toBe("second-workspace-host");
|
||||
});
|
||||
|
||||
it("does not replay a retained host route after a newer picker choice", () => {
|
||||
const settings = createSettingsSelection();
|
||||
const oldRoute = { key: "old-host-screen", pathname: "/settings/hosts/first/host" };
|
||||
settings.enterRoute(oldRoute, "workspace-host");
|
||||
settings.selectHost("second");
|
||||
settings.enterRoute(
|
||||
{ key: "new-host-screen", pathname: "/settings/hosts/second/host" },
|
||||
"workspace-host",
|
||||
);
|
||||
settings.enterRoute(oldRoute, "workspace-host");
|
||||
settings.enterRoute({ key: "app-screen", pathname: "/settings/appearance" }, "workspace-host");
|
||||
expect(settings.store.getState().selectedServerId).toBe("second");
|
||||
settings.enterRoute({ ...oldRoute, key: "explicit-new-entry" }, "workspace-host");
|
||||
expect(settings.store.getState().selectedServerId).toBe("first");
|
||||
});
|
||||
|
||||
it("does not reseed a visit when remembered workspace state changes", () => {
|
||||
const settings = createSettingsSelection();
|
||||
settings.enterRoute({ key: "/settings", pathname: "/settings" }, null);
|
||||
settings.selectHost("chosen-host");
|
||||
settings.enterRoute({ key: "/settings", pathname: "/settings" }, "workspace-host");
|
||||
expect(settings.store.getState().selectedServerId).toBe("chosen-host");
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import { createStore } from "zustand/vanilla";
|
||||
|
||||
interface SettingsRouteEntry {
|
||||
key: string;
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
interface SettingsSelectionState {
|
||||
selectedServerId: string | null;
|
||||
}
|
||||
|
||||
export function createSettingsSelection() {
|
||||
const store = createStore<SettingsSelectionState>(() => ({ selectedServerId: null }));
|
||||
const enteredRoutes = new Map<string, string>();
|
||||
let inSettings = false;
|
||||
|
||||
function enterRoute(
|
||||
{ key, pathname }: SettingsRouteEntry,
|
||||
workspaceServerId: string | null,
|
||||
): void {
|
||||
const isSettings = pathname === "/settings" || pathname.startsWith("/settings/");
|
||||
if (!isSettings) {
|
||||
inSettings = false;
|
||||
enteredRoutes.clear();
|
||||
return;
|
||||
}
|
||||
// A retained route returning through Back must not replay its old host intent.
|
||||
if (enteredRoutes.get(key) === pathname) return;
|
||||
const isNewVisit = !inSettings;
|
||||
inSettings = true;
|
||||
enteredRoutes.set(key, pathname);
|
||||
|
||||
const hostMatch = /^\/settings\/hosts\/([^/]+)(?:\/|$)/.exec(pathname);
|
||||
if (hostMatch) {
|
||||
store.setState({ selectedServerId: decodeURIComponent(hostMatch[1]) });
|
||||
} else if (isNewVisit) {
|
||||
store.setState({ selectedServerId: workspaceServerId });
|
||||
}
|
||||
}
|
||||
|
||||
function selectHost(serverId: string): void {
|
||||
store.setState({ selectedServerId: serverId });
|
||||
}
|
||||
|
||||
return { store, enterRoute, selectHost };
|
||||
}
|
||||
@@ -80,7 +80,6 @@ import {
|
||||
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
||||
import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
|
||||
import { AgentTracks, hasAgentTracks } from "@/panels/agent-tracks";
|
||||
import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
@@ -1110,11 +1109,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
});
|
||||
const hasPluginComposerPills = useHasPluginComposerPills(serverId, workspaceId, agentId);
|
||||
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
|
||||
const parentAgentId = useParentAgentId(serverId, agentId);
|
||||
// With a composer the parent is a pill above it; without one, the box takes the composer's place.
|
||||
const parentPillAgentId = showsComposer ? parentAgentId : null;
|
||||
const hasVisibleAgentTracks = hasAgentTracks({
|
||||
parentAgentId: parentPillAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus: archiveFinishedSubagents.status,
|
||||
@@ -1166,9 +1161,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
composerState,
|
||||
],
|
||||
);
|
||||
const parentCallout = parentAgentId ? (
|
||||
<ParentAgentCallout serverId={serverId} parentAgentId={parentAgentId} />
|
||||
) : null;
|
||||
const composerSection = showsComposer ? (
|
||||
<RenderProfile id={`AgentComposerSection:${agentId}`}>
|
||||
<AgentComposerSection
|
||||
@@ -1187,9 +1179,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
onMessageSent={handleMessageSent}
|
||||
/>
|
||||
</RenderProfile>
|
||||
) : (
|
||||
parentCallout
|
||||
);
|
||||
) : null;
|
||||
const streamContent = (
|
||||
<View style={animatedStaticStyles.content}>
|
||||
{showsComposer ? null : <SheetAgentFacts serverId={serverId} agentId={agentId} />}
|
||||
@@ -1213,7 +1203,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={agentId}
|
||||
parentAgentId={parentPillAgentId}
|
||||
subagentRows={subagentRows}
|
||||
tasks={tasks}
|
||||
archiveFinishedStatus={archiveFinishedSubagents.status}
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
useSubagentOpensInSheet,
|
||||
type SubagentRow,
|
||||
} from "@/subagents";
|
||||
import { ParentAgentPill } from "@/subagents/parent";
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
import type { TodoEntry } from "@/types/stream";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
@@ -35,7 +34,6 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
@@ -45,7 +43,6 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
parentAgentId: string | null;
|
||||
subagentRows: SubagentRow[];
|
||||
tasks: TodoEntry[] | undefined;
|
||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||
@@ -91,12 +88,8 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
[openSubagentTarget, serverId, workspaceId],
|
||||
);
|
||||
const handleOpenProviderSubagent = useCallback(
|
||||
(ownerAgentId: string, subagentId: string) => {
|
||||
openSubagentTarget({
|
||||
kind: "provider_subagent",
|
||||
parentAgentId: ownerAgentId,
|
||||
subagentId,
|
||||
});
|
||||
(parentAgentId: string, subagentId: string) => {
|
||||
openSubagentTarget({ kind: "provider_subagent", parentAgentId, subagentId });
|
||||
},
|
||||
[openSubagentTarget],
|
||||
);
|
||||
@@ -115,7 +108,6 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
if (
|
||||
!hasWorkspaceDiffStat &&
|
||||
!hasAgentTracks({
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
@@ -127,7 +119,6 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
|
||||
return (
|
||||
<ComposerTrackBar>
|
||||
{parentAgentId ? <ParentAgentPill serverId={serverId} parentAgentId={parentAgentId} /> : null}
|
||||
<AgentTaskList tasks={tasks} />
|
||||
<SubagentsTrack
|
||||
serverId={serverId}
|
||||
@@ -156,20 +147,17 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
});
|
||||
|
||||
export function hasAgentTracks({
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
hasPluginComposerPills = false,
|
||||
}: {
|
||||
parentAgentId: string | null;
|
||||
subagentRows: readonly SubagentRow[];
|
||||
tasks: readonly TodoEntry[] | undefined;
|
||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||
hasPluginComposerPills?: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
parentAgentId !== null ||
|
||||
subagentRows.length > 0 ||
|
||||
Boolean(tasks?.length) ||
|
||||
archiveFinishedStatus.kind !== "idle" ||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user