From 61d2a5f0adbda0e30ad3995db82a007228d03b15 Mon Sep 17 00:00:00 2001 From: Louis LUO Date: Sun, 19 Jul 2026 20:51:45 +0800 Subject: [PATCH 01/13] fork: apply OpenCodeUI customizations on top of upstream v0.6.33 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed from 14 individual commits originally authored against v0.6.30, then carried forward through `b00e6f30 Merge main (v0.6.33) into dev`. Rebased onto rewritten upstream/main (00a7ccc4) on 2026-07-19 after maintainer fixed author info — see backup/dev-before-rewrite for the original pre-squash history. Customizations (all verified typecheck+test+build green at original commits; full details in docs/fork-customizations.md): - feat(chat): show workspace location (name · branch) in session header - feat(sidebar): show 'New Chat' draft row at top of current folder - refactor(sidebar): move context-usage indicator from footer to input toolbar; footer slimmed to a cog + connection dot - feat(input): unify model selector into InputToolbar for all viewports (desktop+mobile), removed from Header; toolbar order agent→model→variant - refactor(sidebar): replace project selector toggle+dropdown with a single 'add workspace' button next to New Chat - fix(header): restrict workspace-location picker to desktop only - revert: drop PaneHeader sidebar toggle (reverted the bidirectional toggle that was added then abandoned) - feat(servers): allow deleting the default server when others exist (non-desktop only — Tauri keeps default undeletable because it's the local backend). removeServer guard relaxed from isDefault to isDefault && (isTauri() || length <= 1). - feat(themes): add GitHub + One (Atom) theme presets with authoritative colors (GitHub from primer/primitives; One from tinted-theming/ base16-schemes, accent uses base0B string green not base0D function blue — couples with OpenCodeUI's inline-code/accent.main100 binding) - fix(themes): authoritative base16/Primer colors for GitHub + One - fix(themes): switch One theme accent to Atom string green - feat(code-block): user-configurable Shiki theme (light/dark independent) — 65 bundled themes, lazy-loaded per-theme via Vite chunks, defaults to GitHub to preserve prior behavior - chore: remove TODO.md --- docs/fork-customizations.md | 295 ++++++++++++++++++ src/App.tsx | 2 - src/features/chat/ChatPane.tsx | 5 - src/features/chat/Header.tsx | 46 +-- src/features/chat/SessionHeaderLocation.tsx | 49 +++ .../chat/SessionHeaderLocationPicker.tsx | 138 ++++++++ src/features/chat/Sidebar.tsx | 5 - .../chat/input/ContextUsageButton.tsx | 135 ++++++++ src/features/chat/input/InputToolbar.test.tsx | 4 + src/features/chat/input/InputToolbar.tsx | 34 +- src/features/chat/sessionHeaderContext.ts | 31 ++ .../chat/sidebar/FolderRecentList.tsx | 77 +++-- src/features/chat/sidebar/SidePanel.tsx | 284 ++--------------- src/features/chat/sidebar/SidebarFooter.tsx | 189 ++--------- .../chat/sidebar/draftNewChatSession.ts | 15 + .../sidebar/recentWorkspaceDirectories.ts | 80 +++++ .../chat/useRecentWorkspaceDirectories.ts | 31 ++ .../chat/useSwitchWorkspaceDirectory.ts | 26 ++ .../components/AppearanceSettings.tsx | 3 + .../components/CodeBlockThemeSettings.tsx | 193 ++++++++++++ .../components/ServersSettings.test.tsx | 22 +- .../settings/components/ServersSettings.tsx | 57 ++-- src/hooks/useSyntaxHighlight.test.ts | 10 + src/hooks/useSyntaxHighlight.ts | 34 +- src/hooks/useTheme.ts | 14 + src/lib/codeBlockThemes.ts | 38 +++ src/lib/shikiTheme.ts | 19 +- src/lib/shikiWorkerClient.ts | 18 +- src/locales/en/settings.json | 8 + src/locales/zh-CN/settings.json | 8 + src/store/serverStore.test.ts | 45 +++ src/store/serverStore.ts | 5 +- src/store/themeStore.ts | 48 +++ src/themes/index.ts | 242 ++++++++++++++ src/workers/shikiWorker.ts | 54 +++- 35 files changed, 1726 insertions(+), 538 deletions(-) create mode 100644 docs/fork-customizations.md create mode 100644 src/features/chat/SessionHeaderLocation.tsx create mode 100644 src/features/chat/SessionHeaderLocationPicker.tsx create mode 100644 src/features/chat/input/ContextUsageButton.tsx create mode 100644 src/features/chat/sessionHeaderContext.ts create mode 100644 src/features/chat/sidebar/draftNewChatSession.ts create mode 100644 src/features/chat/sidebar/recentWorkspaceDirectories.ts create mode 100644 src/features/chat/useRecentWorkspaceDirectories.ts create mode 100644 src/features/chat/useSwitchWorkspaceDirectory.ts create mode 100644 src/features/settings/components/CodeBlockThemeSettings.tsx create mode 100644 src/lib/codeBlockThemes.ts diff --git a/docs/fork-customizations.md b/docs/fork-customizations.md new file mode 100644 index 00000000..58b25533 --- /dev/null +++ b/docs/fork-customizations.md @@ -0,0 +1,295 @@ +# Fork 定制记录 + +> 本文档记录本 fork(`SsparKluo/OpenCodeUI`,作者 Louis LUO ``)相对于上游 `lehhair/OpenCodeUI` 的全部定制改动,**目的是在未来 merge upstream 时作为参考**:知道改了什么、为什么改、改了哪些文件,从而预判冲突、避免回退、快速重应用。 + +最后核对基准:上游 `main` = `6d10da9`(v0.6.24),fork 集成分支 `dev` = `c7527af`。 + +--- + +## 1. 分支模型 + +| 分支 | 角色 | +| --------------- | -------------------------------------------------------------------- | +| `upstream/main` | 上游 `lehhair/OpenCodeUI` 主干 | +| `main` | **纯上游镜像**,始终与 `upstream/main` 对齐(`merge-base` 相同) | +| `dev` | **fork 集成分支**,所有 Louis 的定制都在这里;下游功能分支从此拉出 | +| `feat/*`、`fix/*`| 主题功能分支,完成后合并回 `dev` | +| `deploy/cloudflare` | Cloudflare 部署相关配置分支(当前 checkout) | + +**合并 upstream 的标准流程**:`git fetch upstream` → 在 `dev` 上 `git merge upstream/main` → 参考**第 3 节**逐个冲突点核对意图 → 解决冲突后跑 `pnpm test`。`main` 分支仅用于 fast-forward 到 `upstream/main`,**不要在 main 上做任何 fork 改动**。 + +--- + +## 2. 定制概览(按主题) + +fork 共有约 50 个非合并提交,归为 10 个主题。下表按「上游冲突风险」排序——风险越高,merge upstream 时越需要重点关注。 + +| # | 主题 | 类型 | 冲突风险 | 主要文件 | +| - | -------------------------- | --------- | -------- | ----------------------------------------------- | +| 1 | 滚动系统重构 | 重写 | 🔴 高 | `useAutoScroll.ts`、`ChatArea.tsx` | +| 2 | 移动端输入框折叠 | 重写 | 🔴 高 | `InputBox.tsx`、`useMobileCollapse.ts` | +| 3 | 侧边栏 / 项目选择器重构 | 重写 | 🔴 高 | `SidePanel.tsx`、`Header.tsx`、`useSessions.ts` | +| 4 | 模型选择器移到输入栏 | UI 迁移 | 🟡 中 | `Header.tsx`、`InputToolbar.tsx`、`ModelSelector.tsx` | +| 5 | per-block 自动展开控制 | 功能+设置 | 🟡 中 | `MessageRenderer.tsx`、各 PartView、`themeStore.ts` | +| 6 | 触摸手势 / iOS Safari | 平台修复 | 🟡 中 | `ChatArea.tsx`、`scrollGesture.ts`、`index.css` | +| 7 | Diff / 代码预览复制按钮 | 功能 | 🟡 中 | `DiffViewer.tsx`、`CodePreview.tsx`、`diffFormat.ts` | +| 8 | API 错误处理 / 鉴权 | 健壮性 | 🟢 低 | `sdk.ts`、`errorHandling.ts`、`ServersSettings.tsx` | +| 9 | aggregateStepFinish 选项 | 功能+设置 | 🟢 低 | `MessageRenderer.tsx`、`ChatSettings.tsx` | +| 10 | Cloudflare 部署 | 部署专属 | 🟢 低 | `workers/api-proxy/`、`.github/workflows/` | + +--- + +## 3. 主题详解 + +### 3.1 滚动系统重构 🔴 + +**目的**:上游的聊天滚动实现存在抖动、跟随不可靠、加载新内容时跳屏等问题。fork 用 `@tanstack/react-virtual` 虚拟化聊天页面,配合自研的 `useAutoScroll` hook 重建整个滚动系统,实现:稳定跟随、阈值内回弹到底、内容增长时智能保持位置。 + +**关键设计**: +- 聊天页面按 `chatPageModel.ts` 分页,**倒序存储、正序渲染**(最旧页面在顶部)。 +- `useAutoScroll` 维护「跟随模式」,用户向上滚动超出阈值即脱离跟随,回弹到阈值内自动重新跟随。 +- 用 opencode 的 `markBoundaryGesture` 替代原本 250ms 时间窗来识别滚动手势边界(更准确)。 +- 「智能滚动」阈值统一为 60px(`0c729b0`),子代理 Task 视图、胶囊推理块各自适配。 + +**涉及文件**: +- `src/hooks/useAutoScroll.ts`(+ `.test.ts`)— 核心实现,**几乎全量重写** +- `src/features/chat/ChatArea.tsx` — 虚拟化容器 +- `src/features/chat/chatPageModel.ts` + `ChatArea.test.ts` — 页面模型 +- `src/features/chat/ChatPane.tsx` +- `src/features/chat/scrollGesture.ts` + `useScrollGestureDetector.ts`(+ tests)— 手势识别 +- `src/features/message/parts/ReasoningPartView.tsx`、`src/features/message/tools/renderers/TaskRenderer.tsx` — 智能滚动适配 + +**关键 commits**(按 dev 上的最新版本;部分有 rebase 双胞胎已省略): +- `f3b5c6a` feat: overhaul scroll system with useAutoScroll + @tanstack/react-virtual +- `85cd3c7` fix: reverse chat page order so oldest messages render first +- `8563b37` fix: auto-scroll to bottom when last page expands +- `4cc782e` fix: auto-snap to bottom when user scrolls within threshold +- `b4e62b8` fix: debounce snap timer and use instant scroll +- `1c5c18c` refactor: replace 250ms scroll gesture window with markBoundaryGesture +- `0c729b0` fix: unify smart scroll threshold to 60px +- `d326eb3` fix: smart scroll for subagent task view +- `0a49bd6` fix: smart scroll for capsule reasoning block +- `95fe6e9` fix(chat): eliminate auto-scroll jitter when scrolling up slowly near bottom +- `fce3f67`→`fb8c163`(已 revert)尝试在容器 resize 时 re-snap,最终放弃 + +> ⚠️ **上游冲突高发区**。上游对 `ChatArea` / 滚动逻辑改动频繁,每次 merge 几乎必然冲突。核对意图时优先保证 `useAutoScroll` 的「跟随/脱离/回弹」语义不被破坏。 + +--- + +### 3.2 移动端输入框折叠 🔴 + +**目的**:移动端输入框展开/收起原本用 auto-collapse,体验割裂。fork 改为**手动折叠 + CSS `grid-rows` 过渡**,并把 textarea 高度测量从直接读 DOM 改为**隐藏 mirror 元素**测量,避免输入时布局抖动和页脚被顶出可视区。 + +**关键设计**: +- `InputBox.tsx` 用 CSS grid 双行轨道(展开行 / 折叠行)做过渡,折叠时**完全回收**空间(不留 gap — `587f580`)。 +- textarea 高度由 `measureTextareaContentHeight.ts` 隐藏 mirror 测量,`useTextareaAutoHeight.ts` 消费;不再在 auto-resize 时重置高度(`e9bff81`)。 +- Mention/SlashCommand 菜单移出 `overflow-hidden` 容器,防止被裁切(`46a079d`)。 + +**涉及文件**: +- `src/features/chat/InputBox.tsx` — **重灾区**,几乎每次 merge 都冲突 +- `src/features/chat/input/useMobileCollapse.ts` +- `src/features/chat/input/useTextareaAutoHeight.ts` +- `src/features/chat/input/measureTextareaContentHeight.ts`(新增) +- `src/features/chat/input/InputActions.tsx`、`InputToolbar.tsx` +- `src/constants/ui.ts` + +**关键 commits**: +- `d89d815` refactor: mobile input collapse bar with CSS grid-rows transition +- `a28cac2` refactor: replace auto-collapse with manual collapse toggle on mobile +- `55c41ca` fix: restructure expanded track grid so collapse fully reclaims space +- `c5b3601` fix: use textarea mirror for stable height measurement +- `92fbf69` fix: use hidden mirror element to measure textarea content height +- `e9bff81` fix: remove unnecessary textarea height reset on auto-resize +- `587f580` fix: remove collapsed input track gap over chat history +- `b2b8776` fix: drop expandedHeight reference +- `46a079d` fix: move MentionMenu/SlashCommandMenu outside overflow-hidden + +--- + +### 3.3 侧边栏 / 项目选择器重构 🔴 + +**目的**:重做侧边栏顶部交互——用「添加项目」按钮替换原项目选择器,并新增**工作区 header location**(会话头部显示/切换所属工作区目录)。同时让 Sidebar 始终挂载,修复折叠/展开过渡丢失的问题。 + +**关键设计**: +- `SidePanel.tsx` 顶部改为「Open Project」按钮 + 工作区列表。 +- 新增 `SessionHeaderLocation.tsx` / `SessionHeaderLocationPicker.tsx` / `sessionHeaderContext.ts`,把工作区目录绑定到会话 header。 +- 新增 `useRecentWorkspaceDirectories.ts`、`useSwitchWorkspaceDirectory.ts`、`recentWorkspaceDirectories.ts`、`draftNewChatSession.ts` 等工具。 +- `Sidebar.tsx` 始终挂载(`7f16c55`),靠 CSS 控制可见性,保证过渡动画。 + +**涉及文件**: +- `src/features/chat/sidebar/SidePanel.tsx`、`FolderRecentList.tsx` +- `src/features/chat/Header.tsx`、`PaneHeader.tsx` +- `src/features/chat/SessionHeaderLocation.tsx`、`SessionHeaderLocationPicker.tsx`、`sessionHeaderContext.ts`(新增) +- `src/features/chat/sidebar/{draftNewChatSession,recentWorkspaceDirectories}.ts`(新增) +- `src/features/chat/{useRecentWorkspaceDirectories,useSwitchWorkspaceDirectory}.ts`(新增) +- `src/features/sessions/SessionList.tsx`、`src/hooks/useSessions.ts` +- `src/features/chat/{Sidebar.tsx,ChatPane.tsx,InputBox.tsx}`、`src/App.tsx` + +**关键 commits**: +- `261e7b4` refactor: replace project selector with add-project button, add workspace header location +- `c7527af` feat: rename 'add project' to 'open project' and always show new chat in sidebar +- `7f16c55` fix: restore sidebar collapse/expand transition by always mounting Sidebar +- `50c7876` fix: clean up merge fallout — 上次 merge upstream 后的清理性修复 + +--- + +### 3.4 模型选择器移到输入栏 🟡 + +**目的**:把模型选择器从顶部 Header 移到输入框工具栏,让用户在输入时即可切换模型,释放 Header 空间。 + +**涉及文件**: +- `src/features/chat/Header.tsx` — 移除 ModelSelector +- `src/features/chat/input/InputToolbar.tsx` — 接入 ModelSelector +- `src/features/chat/ModelSelector.tsx` +- `src/features/chat/ChatPane.tsx` + +**关键 commits**: +- `15f3ed2` feat: move model selector from header to input toolbar + +> 上游若调整 Header 或 InputToolbar 布局会冲突;语义上很简单,冲突时保留「ModelSelector 在 InputToolbar」即可。 + +--- + +### 3.5 per-block 自动展开控制 🟡 + +**目的**:给 reasoning / subtask / tool 等消息块增加**逐块自动展开控制**,并新增「沉浸式未读工具折叠」模式——活跃工具运行期间,未读的工具块按用户偏好折叠,避免刷屏。 + +**关键设计**: +- 新增 `utils/blockCollapseMode.ts`(+ test)定义折叠模式枚举。 +- 设置项写入 `themeStore.ts`,UI 在 `ChatSettings.tsx`。 +- 各 `PartView` 根据 mode + 「是否活跃运行」决定展开/折叠。 + +**涉及文件**: +- `src/features/message/MessageRenderer.tsx` +- `src/features/message/parts/{ReasoningPartView,SubtaskPartView,ToolPartView}.tsx` +- `src/utils/blockCollapseMode.ts`(新增)+ `.test.ts` +- `src/features/settings/components/ChatSettings.tsx` +- `src/store/themeStore.ts` + +**关键 commits**: +- `a589062` feat: per-block auto-expand control with immersive unread tool option +- `1c455ca` fix: respect immersive unread tool collapse mode during active tool run + +--- + +### 3.6 触摸手势 / iOS Safari 🟡 + +**目的**:移动端在 `ChatArea` 上需要同时支持**水平翻页(pager)手势**和**纵向滚动**,但 iOS Safari 默认 `touch-action` 会拦截斜向手势。fork 通过 `touch-pan-y` + 沿 DOM 链向上传播,让 pager 与滚动共存。(曾尝试给 popup 开启垂直 touch pan,最终 revert——iOS 上副作用大于收益。) + +**涉及文件**: +- `src/features/chat/ChatArea.tsx`、`ChatPane.tsx` +- `src/features/chat/scrollGesture.ts`、`useScrollGestureDetector.ts`(+ tests) +- `src/index.css` — `touch-action` 相关规则 + +**关键 commits**: +- `9a33b19` fix: add touch-pan-y to ChatArea to allow horizontal pager gestures on mobile +- `fa3c13c` fix: propagate touch-pan-y up the DOM chain to enable horizontal pager gestures +- `e0e4397`→`41d9ec7`(已 revert)尝试给 iOS Safari popup 开启垂直 touch pan + +--- + +### 3.7 Diff / 代码预览复制按钮 🟡 + +**目的**:给 diff viewer 和 code preview 加复制按钮,提升可用性。`diffFormat.ts` 抽取统一的 diff 文本格式化逻辑。 + +**涉及文件**: +- `src/components/DiffViewer.tsx`(+ `.test.tsx`)、`DiffView.tsx` +- `src/components/CodePreview.tsx`(+ `.test.tsx`) +- `src/components/ContentBlock.tsx`、`SessionChangesPanel.tsx` +- `src/features/message/tools/renderers/DefaultRenderer.tsx` +- `src/utils/diffFormat.ts`(+ `.test.ts`) + +**关键 commits**: +- `69892a4` feat: add copy button to diff viewer and code preview +- `c30ded2` fix: close JSX expression in CopyButton conditional render(构建修复) + +--- + +### 3.8 API 错误处理 / 鉴权 🟢 + +**目的**:上游对 opencode server 错误静默失败,fork 把错误**通过 toast 显式暴露给用户**;并支持**在默认 server 上配置鉴权凭据**(原本只能对自定义 server 配);**允许在存在其他 server 时删除默认 server**(桌面端除外——桌面端的默认 server 既是配置项也是 app 后端)。 + +**关键设计**(默认 server 可删除): +- `serverStore.removeServer` 守卫由 `server.isDefault` 改为 `server.isDefault && (isTauri() || servers.length <= 1)`。 +- 桌面端(`isTauri()`)禁止删除默认 server——避免 `ServiceSettings` 的「启动本地服务」功能静默失效(`setLocalServerRuntimeUrl` 依赖默认 server 存在)。 +- UI 上删除按钮对默认 server 在 `!isTauri() && servers.length > 1` 时显示;编辑按钮仍隐藏(保持作用域最小)。 +- 持久化无改动——`loadFromStorage` 的「空列表重建默认」逻辑是安全的,因为删除默认 server 必导致列表非空。唯一能清空列表的路径(删完所有 server)会触发重建默认,这是合理的 reset 行为。 + +**涉及文件**: +- `src/api/sdk.ts`、`src/utils/errorHandling.ts`(新增) +- `src/contexts/SessionContext.tsx`、`src/hooks/useSessions.ts` +- `src/features/chat/sidebar/{FolderRecentList,SidePanel}.tsx`(错误展示接入点) +- `src/features/settings/components/ServersSettings.tsx`(+ `.test.tsx`) +- `src/store/serverStore.ts`(+ `.test.ts`)— 删除守卫 + +**关键 commits**: +- `ad838c6` fix: surface opencode server errors to users via toast +- `edd8f49` fix: allow configuring auth credentials on the default server +- (pending)feat: allow deleting the default server when others exist (non-desktop) + +--- + +### 3.9 aggregateStepFinish 选项 🟢 + +**目的**:新增设置项控制 step-finish 事件的展示方式(聚合显示),写入 `themeStore`。 + +**涉及文件**: +- `src/features/message/MessageRenderer.tsx`(+ `.test.tsx`) +- `src/features/settings/components/ChatSettings.tsx` +- `src/store/themeStore.ts` + +**关键 commits**: +- `9fc58a6` feat: add aggregateStepFinish option for step-finish display + +--- + +### 3.10 Cloudflare 部署 🟢 + +**目的**:fork 专属的 Cloudflare Pages + Workers 部署链路。上游无此部分,**理论上不会与上游冲突**(文件隔离),但 merge 后需确认 `pnpm-workspace.yaml` / `package.json` 的 workspace 声明没被上游覆盖。 + +**关键设计**: +- `workers/api-proxy/`:独立子包,用 wrangler 部署的 API 代理 Worker;**显式转发请求头**(`58f57ac`,标准 `RequestInit`)。 +- CI 用 `pnpm --ignore-workspace` 让 api-proxy 独立安装,缓存键单独维护。 +- Pages 用 `_routes.json` 优化路由(`be41fe7`),仅必要路径走 Functions。 + +**涉及文件**: +- `workers/api-proxy/`(`src/index.ts`、`wrangler.toml`、`package.json`、`tsconfig.json`、`vitest.config.ts`) +- `.github/workflows/deploy-worker.yml`、`deploy.yml` +- `docs/cloudflare-pages.md` +- `functions/`(Cloudflare Pages Functions) +- `pnpm-workspace.yaml`(声明 workers/api-proxy 为 workspace 成员) + +**关键 commits**: +- `be41fe7` feat(cloudflare): optimize Pages deployment with `_routes.json` +- `356d8d5` fix(ci): repair deploy-worker pnpm cache on non-monorepo root +- `6cba3ad` fix(ci): use `pnpm --ignore-workspace` for isolated api-proxy +- `58f57ac` fix(worker): explicitly forward request headers via standard RequestInit +- `891a21d` docs(cloudflare): `pnpm install --ignore-workspace` for local api-proxy + +--- + +## 4. 上游合并备忘 + +### 4.1 必然冲突的「热线」文件 + +以下文件被多个主题反复修改,且上游也频繁改动,merge 时几乎必冲突: + +- `src/features/chat/ChatArea.tsx`(滚动 + 触摸) +- `src/features/chat/InputBox.tsx`(移动端折叠) +- `src/features/chat/Header.tsx`(侧边栏 + 模型选择器) +- `src/features/chat/ChatPane.tsx`(多个主题接入) +- `src/features/message/MessageRenderer.tsx`(per-block + aggregateStepFinish) +- `src/hooks/useAutoScroll.ts`(滚动核心,几乎全量重写) + +### 4.2 合并后自检清单 + +1. `pnpm install`(确认 `pnpm-workspace.yaml` 仍含 `workers/api-proxy`) +2. `pnpm test`(重点看 `useAutoScroll`、`blockCollapseMode`、`diffFormat`、`chatPageModel` 相关测试) +3. 移动端实测:输入框折叠/展开过渡、水平翻页手势、textarea 高度跟随 +4. 桌面端实测:模型选择器在输入栏、侧边栏「Open Project」、会话 header 工作区切换 +5. 滚动实测:跟随/脱离/回弹、新内容增长时位置保持 +6. Cloudflare 部署链路(若该分支要发布):`pnpm --ignore-workspace` 在 `workers/api-proxy` 可独立构建 + +### 4.3 维护本文档 + +每次 merge upstream 或新增 fork 主题后,**同步更新第 2、3 节**:新增主题补进概览表,已有主题追加 commits 和文件。保持「每个定制都有目的 + 文件 + commit」三元组,未来任何一次 merge 都能快速还原意图。 diff --git a/src/App.tsx b/src/App.tsx index 7ce65ebd..dc0d673e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -872,7 +872,6 @@ function App() { onNewSession={handleNewSession} onOpen={handleOpenSidebar} onClose={handleCloseSidebar} - contextLimit={focusedController?.contextLimit} onOpenSettings={openSettings} projectDialogOpen={projectDialogOpen} onProjectDialogClose={closeProjectDialog} @@ -957,7 +956,6 @@ function App() { onNewSession={handleNewSession} onOpen={handleOpenSidebar} onClose={handleCloseSidebar} - contextLimit={focusedController?.contextLimit} onOpenSettings={openSettings} projectDialogOpen={projectDialogOpen} onProjectDialogClose={closeProjectDialog} diff --git a/src/features/chat/ChatPane.tsx b/src/features/chat/ChatPane.tsx index 8ca49902..ef5aec90 100644 --- a/src/features/chat/ChatPane.tsx +++ b/src/features/chat/ChatPane.tsx @@ -790,16 +790,11 @@ export const ChatPane = memo(function ChatPane({
diff --git a/src/features/chat/Header.tsx b/src/features/chat/Header.tsx index cce038ea..1088ee6a 100644 --- a/src/features/chat/Header.tsx +++ b/src/features/chat/Header.tsx @@ -10,7 +10,6 @@ import { MinimizeIcon, } from '../../components/Icons' import { IconButton } from '../../components/ui' -import { ModelSelector, type ModelSelectorHandle } from './ModelSelector' import { ShareDialog } from './ShareDialog' import { messageStore, useHeaderSessionMeta } from '../../store' import { useLayoutStore, layoutStore } from '../../store/layoutStore' @@ -19,19 +18,17 @@ import { updateSession } from '../../api' import { useDirectory } from '../../contexts/useDirectory' import { uiErrorHandler } from '../../utils' import { useChatViewport } from './chatViewport' -import type { ModelInfo } from '../../api' +import { useSessionHeaderContext } from './sessionHeaderContext' +import { SessionHeaderLocationPicker } from './SessionHeaderLocationPicker' +import type { SessionHeaderLocation } from './SessionHeaderLocation' + interface HeaderProps { - models: ModelInfo[] - modelsLoading: boolean - selectedModelKey: string | null - onModelChange: (modelKey: string, model: ModelInfo) => void onOpenSidebar?: () => void onToggleRightPanel?: () => void onSplitPane?: () => void isPaneFullscreen?: boolean onTogglePaneFullscreen?: () => void - modelSelectorRef?: React.RefObject } interface SessionTitleControlProps { @@ -39,6 +36,8 @@ interface SessionTitleControlProps { isEditingTitle: boolean editTitle: string sessionTitle: string + workspaceDirectory?: string + sessionLocation?: SessionHeaderLocation | null titleInputRef: React.RefObject setEditTitle: (value: string) => void setIsEditingTitle: (value: boolean) => void @@ -54,6 +53,8 @@ function SessionTitleControl({ isEditingTitle, editTitle, sessionTitle, + workspaceDirectory, + sessionLocation, titleInputRef, setEditTitle, setIsEditingTitle, @@ -80,6 +81,19 @@ function SessionTitleControl({
+ {!compact && sessionLocation && ( + <> + + · + + )} {isEditingTitle ? ( )} - {!isCompact && ( - - )} - {isCompact &&
{titleControl}
}
diff --git a/src/features/chat/SessionHeaderLocation.tsx b/src/features/chat/SessionHeaderLocation.tsx new file mode 100644 index 00000000..4e2aba73 --- /dev/null +++ b/src/features/chat/SessionHeaderLocation.tsx @@ -0,0 +1,49 @@ +import { FolderIcon, GitBranchIcon } from '../../components/Icons' + +export interface SessionHeaderLocation { + workspaceName?: string + branchName?: string +} + +interface SessionHeaderLocationLabelsProps { + location: SessionHeaderLocation + textClassName: string + iconSize?: number + workspaceMaxWidthClass?: string + branchMaxWidthClass?: string +} + +export function SessionHeaderLocationLabels({ + location, + textClassName, + iconSize = 12, + workspaceMaxWidthClass = 'max-w-[120px]', + branchMaxWidthClass = 'max-w-[120px]', +}: SessionHeaderLocationLabelsProps) { + const { workspaceName, branchName } = location + if (!workspaceName && !branchName) return null + + return ( +
+ {workspaceName && ( + + + {workspaceName} + + )} + {workspaceName && branchName && ·} + {branchName && ( + + + {branchName} + + )} +
+ ) +} \ No newline at end of file diff --git a/src/features/chat/SessionHeaderLocationPicker.tsx b/src/features/chat/SessionHeaderLocationPicker.tsx new file mode 100644 index 00000000..9b02123f --- /dev/null +++ b/src/features/chat/SessionHeaderLocationPicker.tsx @@ -0,0 +1,138 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { ChevronDownIcon, FolderIcon, GitBranchIcon } from '../../components/Icons' +import { useDropdown, useGitWorkspaceCatalog, useVcsInfo } from '../../hooks' +import { isSameDirectory, normalizeToForwardSlash } from '../../utils' +import { SessionHeaderLocationLabels, type SessionHeaderLocation } from './SessionHeaderLocation' +import { getWorkspaceDisplayName } from './sidebar/recentWorkspaceDirectories' +import { useRecentWorkspaceDirectories } from './useRecentWorkspaceDirectories' +import { useSwitchWorkspaceDirectory } from './useSwitchWorkspaceDirectory' + +interface SessionHeaderLocationPickerProps { + currentDirectory?: string + location: SessionHeaderLocation + textClassName: string + iconSize?: number + workspaceMaxWidthClass?: string + branchMaxWidthClass?: string +} + +function WorkspaceDropdownOption({ + directory, + isSelected, + onSelect, +}: { + directory: string + isSelected: boolean + onSelect: () => void +}) { + const normalized = normalizeToForwardSlash(directory) + const catalogInput = useMemo(() => [normalized], [normalized]) + const { catalog } = useGitWorkspaceCatalog(catalogInput) + const { vcsInfo } = useVcsInfo(normalized) + const workspaceName = getWorkspaceDisplayName(normalized, catalog) + const branchName = vcsInfo?.branch + + return ( + + ) +} + +export function SessionHeaderLocationPicker({ + currentDirectory, + location, + textClassName, + iconSize = 12, + workspaceMaxWidthClass, + branchMaxWidthClass, +}: SessionHeaderLocationPickerProps) { + const { t } = useTranslation('chat') + const switchWorkspace = useSwitchWorkspaceDirectory() + const workspaceDirectories = useRecentWorkspaceDirectories(currentDirectory) + const { isOpen, toggle, close, triggerRef, menuRef } = useDropdown() + const normalizedCurrent = currentDirectory ? normalizeToForwardSlash(currentDirectory) : undefined + const canSwitch = workspaceDirectories.length > 0 + + if (!location.workspaceName && !location.branchName) return null + + return ( +
+ + + {canSwitch && ( +
+
+
+ {t('header.recentWorkspaces')} +
+
+ {workspaceDirectories.map(directory => ( + { + switchWorkspace(directory) + close() + }} + /> + ))} +
+
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/src/features/chat/Sidebar.tsx b/src/features/chat/Sidebar.tsx index 6b04ec91..c8221ba7 100644 --- a/src/features/chat/Sidebar.tsx +++ b/src/features/chat/Sidebar.tsx @@ -23,7 +23,6 @@ interface SidebarProps { onNewSession: () => void onOpen: () => void onClose: () => void - contextLimit?: number onOpenSettings?: () => void projectDialogOpen?: boolean onProjectDialogClose?: () => void @@ -37,7 +36,6 @@ export const Sidebar = memo(function Sidebar({ onNewSession, onOpen, onClose, - contextLimit, onOpenSettings, projectDialogOpen, onProjectDialogClose, @@ -291,7 +289,6 @@ export const Sidebar = memo(function Sidebar({ isMobile={true} isExpanded={true} onToggleSidebar={onClose} - contextLimit={contextLimit} onOpenSettings={onOpenSettings} /> @@ -346,7 +343,6 @@ export const Sidebar = memo(function Sidebar({ isMobile={true} isExpanded={true} onToggleSidebar={onClose} - contextLimit={contextLimit} onOpenSettings={onOpenSettings} /> @@ -382,7 +378,6 @@ export const Sidebar = memo(function Sidebar({ isMobile={false} isExpanded={isOpen} onToggleSidebar={handleToggle} - contextLimit={contextLimit} onOpenSettings={onOpenSettings} /> diff --git a/src/features/chat/input/ContextUsageButton.tsx b/src/features/chat/input/ContextUsageButton.tsx new file mode 100644 index 00000000..9ff4bd9a --- /dev/null +++ b/src/features/chat/input/ContextUsageButton.tsx @@ -0,0 +1,135 @@ +import { useState, useCallback, useRef, useEffect, memo } from 'react' +import { useTranslation } from 'react-i18next' +import { CircularProgress } from '../../../components/CircularProgress' +import { useSessionStats, formatTokens, formatCost } from '../../../hooks' +import { useMessageStore } from '../../../store' +import { ContextDetailsDialog } from '../sidebar/ContextDetailsDialog' +import { IconButton, DropdownMenu } from '../../../components/ui' + +interface ContextUsageButtonProps { + contextLimit?: number + disabled?: boolean +} + +export const ContextUsageButton = memo(function ContextUsageButton({ + contextLimit = 200000, + disabled = false, +}: ContextUsageButtonProps) { + const { t } = useTranslation('chat') + const { messages } = useMessageStore() + const stats = useSessionStats(contextLimit) + const hasMessages = messages.length > 0 + const [menuOpen, setMenuOpen] = useState(false) + const [dialogOpen, setDialogOpen] = useState(false) + const triggerRef = useRef(null) + const menuRef = useRef(null) + + const percent = Math.min(Math.max(stats.contextPercent, 0), 100) + const progressColor = + percent === 0 + ? 'text-text-500' + : percent >= 90 + ? 'text-danger-100' + : percent >= 70 + ? 'text-warning-100' + : 'text-accent-main-100' + + const statsBarColor = + stats.contextPercent >= 90 ? 'bg-danger-100' : stats.contextPercent >= 70 ? 'bg-warning-100' : 'bg-accent-main-100' + + const toggleMenu = useCallback(() => { + if (disabled) return + setMenuOpen(open => !open) + }, [disabled]) + + const openDetailsDialog = useCallback(() => { + setMenuOpen(false) + setDialogOpen(true) + }, []) + + useEffect(() => { + if (!menuOpen) return + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as Node + if (triggerRef.current?.contains(target)) return + if (menuRef.current?.contains(target)) return + setMenuOpen(false) + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [menuOpen]) + + const title = `Context: ${formatTokens(hasMessages ? stats.contextUsed : 0)} / ${formatTokens(stats.contextLimit)} · ${Math.round(percent)}%` + + return ( + <> + + + + + + + +
+
+ {t('sidebar.contextUsage')} +
+ {Math.round(stats.contextPercent)}% + +
+
+
+
+
+
+ + {formatTokens(hasMessages ? stats.contextUsed : 0)} / {formatTokens(stats.contextLimit)} + + {formatCost(stats.totalCost)} +
+
+ + + setDialogOpen(false)} contextLimit={stats.contextLimit} /> + + ) +}) \ No newline at end of file diff --git a/src/features/chat/input/InputToolbar.test.tsx b/src/features/chat/input/InputToolbar.test.tsx index b51e8867..3232a705 100644 --- a/src/features/chat/input/InputToolbar.test.tsx +++ b/src/features/chat/input/InputToolbar.test.tsx @@ -90,6 +90,10 @@ vi.mock('../ModelSelector', () => ({ ModelSelector: () => null, })) +vi.mock('./ContextUsageButton', () => ({ + ContextUsageButton: () => null, +})) + describe('InputToolbar file selection', () => { beforeEach(() => { useIsMobileMock.mockReturnValue(false) diff --git a/src/features/chat/input/InputToolbar.tsx b/src/features/chat/input/InputToolbar.tsx index d57d6a4d..d8291248 100644 --- a/src/features/chat/input/InputToolbar.tsx +++ b/src/features/chat/input/InputToolbar.tsx @@ -3,8 +3,10 @@ import { useTranslation } from 'react-i18next' import { ChevronDownIcon, SendIcon, StopIcon, PaperclipIcon, AgentIcon, ThinkingIcon } from '../../../components/Icons' import { DropdownMenu, MenuItem, IconButton, AnimatedPresence } from '../../../components/ui' import { ModelSelector, type ModelSelectorHandle } from '../ModelSelector' +import { ContextUsageButton } from './ContextUsageButton' import { useChatViewport } from '../chatViewport' import { isTauri, isTauriMobile, extToMime } from '../../../utils/tauri' +import { getModelKey } from '../../../utils/modelUtils' import type { ApiAgent } from '../../../api/client' import type { ModelInfo, FileCapabilities } from '../../../api' @@ -67,6 +69,7 @@ export function InputToolbar({ const caps = fileCapabilities ?? { image: false, pdf: false, audio: false, video: false } const supportsAnyFile = caps.image || caps.pdf || caps.audio || caps.video const controlsDisabled = isSending + const contextLimit = models.find(model => getModelKey(model) === selectedModelKey)?.contextLimit // 动态构建 HTML accept 和 Tauri filter const { acceptString, tauriFilters } = useMemo(() => { @@ -307,22 +310,8 @@ export function InputToolbar({ return (
- {/* Left side: Model (mobile) + Agent + Variant selectors */} + {/* Left side: Agent + Model + Variant selectors */}
- {/* Model Selector — 移动端显示在最左边 */} - {isCompact && onModelChange && ( - - )} - {/* Agent Selector */} 1} className={isCompact ? 'shrink-0' : ''}>
@@ -403,6 +392,20 @@ export function InputToolbar({
+ {/* Model Selector */} + {onModelChange && ( + + )} + {/* Variant Selector */} 0} className={isCompact ? 'shrink-0' : ''}>
@@ -493,6 +496,7 @@ export function InputToolbar({ {/* Action Buttons */}
+ <> {/* 浏览器模式下的隐藏文件输入 */} diff --git a/src/features/chat/sessionHeaderContext.ts b/src/features/chat/sessionHeaderContext.ts new file mode 100644 index 00000000..fc15a4dd --- /dev/null +++ b/src/features/chat/sessionHeaderContext.ts @@ -0,0 +1,31 @@ +import { useMemo } from 'react' +import { useGitWorkspaceCatalog, useVcsInfo } from '../../hooks' +import { getDirectoryName, isSameDirectory, normalizeToForwardSlash } from '../../utils' +import type { SessionHeaderLocation } from './SessionHeaderLocation' + +export function useSessionHeaderContext(directory?: string): SessionHeaderLocation | null { + const normalizedDirectory = directory ? normalizeToForwardSlash(directory) : undefined + const catalogDirectories = useMemo( + () => (normalizedDirectory ? [normalizedDirectory] : []), + [normalizedDirectory], + ) + const { catalog } = useGitWorkspaceCatalog(catalogDirectories) + const { vcsInfo, isLoading: isBranchLoading } = useVcsInfo(normalizedDirectory) + + return useMemo(() => { + if (!normalizedDirectory) return null + + const meta = catalog.get(normalizedDirectory) + const workspaceName = meta?.isGit + ? isSameDirectory(meta.rootDirectory, normalizedDirectory) + ? getDirectoryName(meta.rootDirectory) || meta.rootDirectory + : getDirectoryName(normalizedDirectory) || normalizedDirectory + : getDirectoryName(normalizedDirectory) || normalizedDirectory + + const branchName = meta?.isGit ? (vcsInfo?.branch ?? (isBranchLoading ? '...' : undefined)) : undefined + + if (!workspaceName && !branchName) return null + + return { workspaceName, branchName } + }, [catalog, normalizedDirectory, vcsInfo?.branch, isBranchLoading]) +} \ No newline at end of file diff --git a/src/features/chat/sidebar/FolderRecentList.tsx b/src/features/chat/sidebar/FolderRecentList.tsx index f8ffe55a..0746e2c7 100644 --- a/src/features/chat/sidebar/FolderRecentList.tsx +++ b/src/features/chat/sidebar/FolderRecentList.tsx @@ -5,7 +5,6 @@ import { FolderIcon, FolderOpenIcon, GitBranchIcon, - GlobeIcon, GripVerticalIcon, PinIcon, SpinnerIcon, @@ -24,6 +23,7 @@ import { pinnedSessionsStore, type PinnedSessionEntry } from '../../../store/pin import { SessionListItem } from '../../sessions' import { getSelectionRoundClass } from '../../sessions/selectionRound' import { SessionChildrenSlot } from './SessionChildrenSlot' +import { createDraftNewChatSession, isDraftNewChatSession } from './draftNewChatSession' const DIRECTORY_PAGE_SIZE = 5 @@ -154,7 +154,7 @@ function getInitialExpandedProjectIds(projects: FolderRecentProject[], currentDi const currentProject = currentDirectory ? projects.find(project => isSameDirectory(project.worktree, currentDirectory)) - : projects.find(project => project.id === 'global') + : undefined return [currentProject?.id || projects[0].id] } @@ -164,11 +164,9 @@ function areProjectIdListsEqual(left: string[], right: string[]) { } function getCurrentProjectId(projects: FolderRecentProject[], currentDirectory?: string) { - if (!currentDirectory) { - const globalProject = projects.find(project => project.id === 'global') - return globalProject?.id - } - return projects.find(project => isSameDirectory(project.worktree, currentDirectory))?.id + return currentDirectory + ? projects.find(project => isSameDirectory(project.worktree, currentDirectory))?.id + : undefined } function reconcileExpandedProjectIds(prev: string[], projects: FolderRecentProject[], currentDirectory?: string) { @@ -777,6 +775,18 @@ function UnavailablePinnedSessionItem({ entry }: { entry: PinnedSessionEntry }) ) } +function shouldShowDraftNewChatInFolder(options: { + isEditMode?: boolean + selectedSessionId: string | null + currentDirectory?: string + folderDirectory: string +}) { + if (options.isEditMode) return false + if (options.selectedSessionId) return false + if (!options.currentDirectory) return false + return isSameDirectory(options.currentDirectory, options.folderDirectory) +} + interface FolderRecentSectionProps { project: FolderRecentProject isExpanded: boolean @@ -885,6 +895,18 @@ function FolderRecentSection({ return sessions.filter(session => !pinnedSet.has(session.id)) }, [pinnedEntries, sessions]) + const showDraftNewChat = shouldShowDraftNewChatInFolder({ + isEditMode, + selectedSessionId, + currentDirectory, + folderDirectory: project.worktree, + }) + const draftNewChatTitle = t('header.newChat') + const sessionsForFolderList = useMemo(() => { + if (!showDraftNewChat) return visibleSessions + return [createDraftNewChatSession(project.worktree, draftNewChatTitle), ...visibleSessions] + }, [showDraftNewChat, visibleSessions, project.worktree, draftNewChatTitle]) + const handleRename = useCallback( async (sessionId: string, newTitle: string) => { const session = sessions.find(item => item.id === sessionId) @@ -912,13 +934,7 @@ function FolderRecentSection({ ? (vcsInfo?.branch ?? (isBranchLoading ? '...' : workspaceFallbackName)) : project.name || workspaceFallbackName const FolderDisplayIcon = - project.id === 'global' - ? GlobeIcon - : sectionKind === 'workspace' - ? GitBranchIcon - : isExpanded - ? FolderOpenIcon - : FolderIcon + sectionKind === 'workspace' ? GitBranchIcon : isExpanded ? FolderOpenIcon : FolderIcon // 展开时:文件夹与首条 session 可拼成连续选中块 const firstVisibleSessionChecked = @@ -1063,34 +1079,41 @@ function FolderRecentSection({ draggableWorkspaceDirectories={draggableWorkspaceDirectories} onReorderWorkspace={onReorderWorkspace} /> - ) : visibleSessions.length === 0 ? ( + ) : sessionsForFolderList.length === 0 ? (
{t('sidebar.noChatsInFolder')}
) : ( <> - {visibleSessions.map((session, index) => { - const isChecked = selectedSessionIds?.has(session.id) ?? false + {sessionsForFolderList.map((session, index) => { + const isDraft = isDraftNewChatSession(session) + const isChecked = isDraft ? false : (selectedSessionIds?.has(session.id) ?? false) // 上:前一条 session,或(首条时)父文件夹已选中 const prevChecked = isEditMode && (index > 0 - ? (selectedSessionIds?.has(visibleSessions[index - 1].id) ?? false) + ? (selectedSessionIds?.has(sessionsForFolderList[index - 1].id) ?? false) : isProjectChecked) // 下:下一条 session,或(末条时)下一个文件夹已选中 const nextChecked = isEditMode && - (index < visibleSessions.length - 1 - ? (selectedSessionIds?.has(visibleSessions[index + 1].id) ?? false) + (index < sessionsForFolderList.length - 1 + ? (selectedSessionIds?.has(sessionsForFolderList[index + 1].id) ?? false) : nextProjectChecked) return (
onSelectSession(session)} - onRename={newTitle => handleRename(session.id, newTitle)} - onDelete={() => handleDelete(session.id)} + isSelected={isDraft ? !selectedSessionId : session.id === selectedSessionId} + onSelect={() => { + if (!isDraft) onSelectSession(session) + }} + onRename={newTitle => { + if (!isDraft) handleRename(session.id, newTitle) + }} + onDelete={() => { + if (!isDraft) handleDelete(session.id) + }} preferTouchUi={preferTouchUi} density="minimal" showStats={showSessionDiffStats} @@ -1100,9 +1123,9 @@ function FolderRecentSection({ checkedPrev={prevChecked} checkedNext={nextChecked} onToggleCheck={ - onToggleSessionSelection - ? options => onToggleSessionSelection(session.id, options) - : undefined + isDraft || !onToggleSessionSelection + ? undefined + : options => onToggleSessionSelection(session.id, options) } /> {onSelectChildSession && diff --git a/src/features/chat/sidebar/SidePanel.tsx b/src/features/chat/sidebar/SidePanel.tsx index 5040787f..dcbd93d0 100644 --- a/src/features/chat/sidebar/SidePanel.tsx +++ b/src/features/chat/sidebar/SidePanel.tsx @@ -8,21 +8,18 @@ import { ActiveSessionItem } from './ActiveSessionItem' import { NotificationItem } from './NotificationItem' import { SidebarFooter } from './SidebarFooter' import { buildActiveSessionTree } from './activeSessionTree' -import { getParentPath } from './sidebarUtils' import { SidebarIcon, FolderIcon, - GlobeIcon, PlusIcon, TrashIcon, SearchIcon, - ChevronDownIcon, ListFilterIcon, FolderMinusIcon, CheckIcon, SpinnerIcon, } from '../../../components/Icons' -import { useDirectory, useKeybindingLabel, useGitWorkspaceCatalog, useVcsInfo } from '../../../hooks' +import { useDirectory, useKeybindingLabel, useGitWorkspaceCatalog } from '../../../hooks' import { useSessionContext } from '../../../contexts/useSessionContext' import { useLayoutStore, childSessionStore } from '../../../store' import { useBusySessions, useBusyCount } from '../../../store/activeSessionStore' @@ -56,7 +53,6 @@ interface SidePanelProps { isMobile?: boolean isExpanded?: boolean onToggleSidebar: () => void - contextLimit?: number onOpenSettings?: () => void } @@ -109,7 +105,6 @@ export function SidePanel({ isMobile = false, isExpanded = true, onToggleSidebar, - contextLimit = 200000, onOpenSettings, }: SidePanelProps) { const { t } = useTranslation(['chat', 'common']) @@ -120,7 +115,6 @@ export function SidePanel({ removeDirectory, addDirectory, reorderDirectories, - recentProjects, } = useDirectory() const catalogDirectories = useMemo( () => @@ -135,13 +129,7 @@ export function SidePanel({ ) const { catalog: gitWorkspaceCatalog, isLoading: isGitWorkspaceCatalogLoading } = useGitWorkspaceCatalog(catalogDirectories) - const { vcsInfo: currentDirectoryVcsInfo, isLoading: isCurrentDirectoryVcsLoading } = useVcsInfo(currentDirectory) const { sidebarFolderRecents, sidebarShowChildSessions } = useLayoutStore() - const [globalFolderIndex, setGlobalFolderIndex] = useState(() => { - const saved = localStorage.getItem('opencode-sidebar-global-folder-index') - const parsed = saved ? Number.parseInt(saved, 10) : 0 - return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0 - }) const normalizedCurrentDirectory = useMemo( () => (currentDirectory ? normalizeToForwardSlash(currentDirectory) : undefined), [currentDirectory], @@ -151,7 +139,6 @@ export function SidePanel({ isOpen: false, projectId: null, }) - const [projectsExpanded, setProjectsExpanded] = useState(false) const [sidebarTab, setSidebarTab] = useState<'recents' | 'active'>('recents') const [expandedRecentProjectIds, setExpandedRecentProjectIds] = useState([]) @@ -162,8 +149,6 @@ export function SidePanel({ const sessionSelectionAnchorIdRef = useRef(null) const projectSelectionAnchorIdRef = useRef(null) const recentsSelectionRootRef = useRef(null) - const projectToggleRef = useRef(null) - const projectsDropdownRef = useRef(null) // 批量删除确认弹窗 const [batchDeleteSessionConfirm, setBatchDeleteSessionConfirm] = useState(false) const [batchRemoveProjectConfirm, setBatchRemoveProjectConfirm] = useState(false) @@ -260,14 +245,6 @@ export function SidePanel({ const showLabels = isExpanded || isMobile const newChatShortcut = useKeybindingLabel('newSession') - useEffect(() => { - if (showLabels && projectsExpanded) return - const activeElement = document.activeElement as Node | null - if (activeElement && projectsDropdownRef.current?.contains(activeElement)) { - projectToggleRef.current?.focus() - } - }, [projectsExpanded, showLabels]) - // Active sessions const busySessions = useBusySessions() const busyCount = useBusyCount() @@ -556,16 +533,6 @@ export function SidePanel({ return buildProjectGroups(savedDirectories) }, [buildProjectGroups, savedDirectories]) - const selectorProjectGroups = useMemo(() => { - const sortedDirectories = [...savedDirectories].sort((a, b) => { - const aTime = recentProjects[a.path] || a.addedAt - const bTime = recentProjects[b.path] || b.addedAt - return bTime - aTime - }) - - return buildProjectGroups(sortedDirectories) - }, [buildProjectGroups, recentProjects, savedDirectories]) - const globalProject = useMemo( () => ({ id: 'global', @@ -575,10 +542,6 @@ export function SidePanel({ [t], ) - const projects = useMemo(() => { - return [globalProject, ...selectorProjectGroups] - }, [globalProject, selectorProjectGroups]) - const currentProject = useMemo(() => { if (!currentDirectory) return globalProject @@ -600,26 +563,6 @@ export function SidePanel({ } }, [currentDirectory, folderProjectGroups, gitWorkspaceCatalog, globalProject, normalizedCurrentDirectory]) - const currentProjectLabel = useMemo(() => { - const baseLabel = currentProject?.name || t('sidebar.global') - if (!currentDirectory || currentProject?.id === 'global') return baseLabel - - const branchLabel = currentDirectoryVcsInfo?.branch ?? (isCurrentDirectoryVcsLoading ? '...' : undefined) - return branchLabel ? `${baseLabel} · ${branchLabel}` : baseLabel - }, [ - currentDirectory, - currentDirectoryVcsInfo?.branch, - currentProject?.id, - currentProject?.name, - isCurrentDirectoryVcsLoading, - t, - ]) - - const globalFolderProject = useMemo( - () => ({ id: 'global', worktree: '', name: t('sidebar.global'), canReorder: true }), - [t], - ) - const folderProjects = useMemo(() => { const list = [...folderProjectGroups] @@ -627,9 +570,8 @@ export function SidePanel({ list.push({ ...currentProject, canReorder: false }) } - const insertAt = Math.min(Math.max(globalFolderIndex, 0), list.length) - return [...list.slice(0, insertAt), globalFolderProject, ...list.slice(insertAt)] - }, [folderProjectGroups, currentDirectory, currentProject, globalFolderProject, globalFolderIndex]) + return list + }, [folderProjectGroups, currentDirectory, currentProject]) const canShowFolderRecents = sidebarFolderRecents && !search && folderProjects.length > 0 const workspaceDirectoriesByProjectId = useMemo(() => { @@ -704,18 +646,6 @@ export function SidePanel({ [allDisplayedProjects], ) - const handleSelectProject = useCallback( - (projectId: string) => { - if (projectId === 'global') { - setCurrentDirectory(undefined) - } else { - setCurrentDirectory(projectId) - } - setProjectsExpanded(false) - }, - [setCurrentDirectory], - ) - const handleRemoveProject = useCallback( (projectId: string) => { getProjectDirectoriesToRemove(projectId).forEach(directory => removeDirectory(directory)) @@ -725,45 +655,14 @@ export function SidePanel({ const handleReorderProjectGroup = useCallback( (draggedId: string, targetId: string) => { - const draggedIdx = folderProjects.findIndex(project => project.id === draggedId) - const targetIdx = folderProjects.findIndex(project => project.id === targetId) - if (draggedIdx === -1 || targetIdx === -1 || draggedIdx === targetIdx) return - - const draggedIsGlobal = folderProjects[draggedIdx].id === 'global' - const targetIsGlobal = folderProjects[targetIdx].id === 'global' - - if (draggedIsGlobal) { - // 全局移到 target 位置:globalFolderIndex 直接等于 targetIdx - if (targetIdx !== globalFolderIndex) { - setGlobalFolderIndex(targetIdx) - localStorage.setItem('opencode-sidebar-global-folder-index', String(targetIdx)) - } - return - } - - if (targetIsGlobal) { - // 普通目录拖到全局位置 = 交换:全局到普通目录原位,普通目录移到全局旁 - const adjacentIdx = draggedIdx < targetIdx ? targetIdx - 1 : targetIdx + 1 - if (draggedIdx !== adjacentIdx) { - const draggedReorderPath = folderProjects[draggedIdx].reorderPath - const adjacentReorderPath = folderProjects[adjacentIdx].reorderPath - if (draggedReorderPath && adjacentReorderPath) { - reorderDirectories(draggedReorderPath, adjacentReorderPath) - } - } - if (draggedIdx !== globalFolderIndex) { - setGlobalFolderIndex(draggedIdx) - localStorage.setItem('opencode-sidebar-global-folder-index', String(draggedIdx)) - } - return - } - - const draggedReorderPath = folderProjects[draggedIdx].reorderPath - const targetReorderPath = folderProjects[targetIdx].reorderPath + const draggedProject = folderProjects.find(project => project.id === draggedId) + const targetProject = folderProjects.find(project => project.id === targetId) + const draggedReorderPath = draggedProject?.reorderPath + const targetReorderPath = targetProject?.reorderPath if (!draggedReorderPath || !targetReorderPath) return reorderDirectories(draggedReorderPath, targetReorderPath) }, - [folderProjects, reorderDirectories, globalFolderIndex], + [folderProjects, reorderDirectories], ) const handleSelect = useCallback( @@ -936,20 +835,6 @@ export function SidePanel({ onToggleProjectSelection: toggleProjectSelection, } - useEffect(() => { - let frameId: number | null = null - - if (!isExpanded) { - frameId = requestAnimationFrame(() => { - setProjectsExpanded(false) - }) - } - - return () => { - if (frameId !== null) cancelAnimationFrame(frameId) - } - }, [isExpanded]) - // 统一的结构,通过 CSS 控制显示/隐藏 return (
@@ -1019,141 +904,29 @@ export function SidePanel({ - {/* Project Selector - 只在展开时显示 */} - {showLabels && ( - - )} - - {/* Projects Dropdown */} -
-
-
- {projects.map(project => { - const isGlobal = project.id === 'global' - const isActive = currentProject?.id === project.id - const itemLabel = - isActive && !isGlobal - ? currentProjectLabel - : project.name || (isGlobal ? t('sidebar.global') : project.worktree) - return ( -
handleSelectProject(project.id)} - className={`group w-full flex items-center gap-2 px-2 py-1.5 rounded-md transition-colors ${ - isActive ? 'bg-bg-200/60 text-text-100' : 'text-text-300 hover:text-text-100 hover:bg-bg-200/50' - }`} - > - - {!isGlobal && ( - - )} -
- ) - })} -
-
-
- -
-
-
+ + + + + {t('sidebar.addProject')} + +
{/* ===== Main Content ===== */} @@ -1423,7 +1196,6 @@ export function SidePanel({ diff --git a/src/features/chat/sidebar/SidebarFooter.tsx b/src/features/chat/sidebar/SidebarFooter.tsx index 15289d87..7e0af2c3 100644 --- a/src/features/chat/sidebar/SidebarFooter.tsx +++ b/src/features/chat/sidebar/SidebarFooter.tsx @@ -2,7 +2,6 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import { createPortal } from 'react-dom' import { ShareDialog } from '../ShareDialog' -import { ContextDetailsDialog } from './ContextDetailsDialog' import { CogIcon, SunIcon, @@ -12,82 +11,20 @@ import { MinimizeIcon, ShareIcon, } from '../../../components/Icons' -import { CircularProgress } from '../../../components/CircularProgress' -import { formatTokens, formatCost, useTheme, useSessionStats } from '../../../hooks' -import { useHasMessages } from '../../../store' - -// 状态指示器 - 圆环 + 右下角状态点 -function StatusIndicator({ - percent, - connectionState, - size = 24, -}: { - percent: number - connectionState: string - size?: number -}) { - const clampedPercent = Math.min(Math.max(percent, 0), 100) - - // 进度颜色 - const progressColor = - clampedPercent === 0 - ? 'text-text-500' - : clampedPercent >= 90 - ? 'text-danger-100' - : clampedPercent >= 70 - ? 'text-warning-100' - : 'text-accent-main-100' - - // 连接状态颜色 - const statusColor = - connectionState === 'connected' - ? 'bg-success-100' - : connectionState === 'connecting' - ? 'bg-warning-100 animate-pulse' - : connectionState === 'error' - ? 'bg-danger-100' - : 'bg-text-500' - - return ( -
- - - {/* 右下角状态点 - 带背景边框以突出显示 */} -
-
- ) -} +import { useTheme } from '../../../hooks' export interface SidebarFooterProps { showLabels: boolean connectionState: string - contextLimit?: number onOpenSettings?: () => void } -export function SidebarFooter({ - showLabels, - connectionState, - contextLimit = 200000, - onOpenSettings, -}: SidebarFooterProps) { +export function SidebarFooter({ showLabels, connectionState, onOpenSettings }: SidebarFooterProps) { const { t } = useTranslation(['chat', 'common']) const { mode: themeMode, setThemeWithAnimation: onThemeChange, isWideMode, toggleWideMode } = useTheme() - // 统计与 hasMessages 留在 footer:流式时不让整个 SidePanel 跟着 messages 重渲 - const hasMessages = useHasMessages() - const stats = useSessionStats(contextLimit) const [isOpen, setIsOpen] = useState(false) const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 260, fromBottom: false }) const [shareDialogOpen, setShareDialogOpen] = useState(false) - const [contextDialogOpen, setContextDialogOpen] = useState(false) const [isVisible, setIsVisible] = useState(false) const prevShowLabelsRef = useRef(showLabels) const containerRef = useRef(null) @@ -95,7 +32,6 @@ export function SidebarFooter({ const menuRef = useRef(null) const closeTimeoutIdRef = useRef | null>(null) - // 菜单中连接状态显示用 const statusColorClass = { connected: 'bg-success-100', @@ -104,10 +40,6 @@ export function SidebarFooter({ error: 'bg-danger-100', }[connectionState] || 'bg-text-500' - const statsColor = - stats.contextPercent >= 90 ? 'bg-danger-100' : stats.contextPercent >= 70 ? 'bg-warning-100' : 'bg-accent-main-100' - - // 打开菜单 const openMenu = useCallback(() => { if (!buttonRef.current || !containerRef.current) return @@ -116,7 +48,6 @@ export function SidebarFooter({ const menuWidth = showLabels ? containerRect.width : 260 if (showLabels) { - // 展开模式:菜单底部在容器上方,留点间隙 setMenuPos({ top: containerRect.top - 8, left: containerRect.left, @@ -124,12 +55,11 @@ export function SidebarFooter({ fromBottom: true, }) } else { - // 收起模式:菜单在按钮右侧,底部对齐按钮底部 setMenuPos({ - top: buttonRect.bottom, // 用作 bottom 计算的参考点 - left: buttonRect.right + 16, // 间距增加到 16px + top: buttonRect.bottom, + left: buttonRect.right + 16, width: 260, - fromBottom: true, // 也用 bottom 定位 + fromBottom: true, }) } @@ -137,22 +67,17 @@ export function SidebarFooter({ requestAnimationFrame(() => setIsVisible(true)) }, [showLabels]) - // 关闭菜单 const closeMenu = useCallback(() => { setIsVisible(false) - // 使用 ref 追踪 timeout 以便清理 const closeTimeoutId = setTimeout(() => setIsOpen(false), 150) - // 保存到 ref 以便清理 closeTimeoutIdRef.current = closeTimeoutId }, []) - // 切换菜单 const toggleMenu = useCallback(() => { if (isOpen) closeMenu() else openMenu() }, [isOpen, openMenu, closeMenu]) - // 点击外部关闭 useEffect(() => { if (!isOpen) return @@ -167,7 +92,6 @@ export function SidebarFooter({ return () => document.removeEventListener('mousedown', handleClickOutside) }, [isOpen, closeMenu]) - // ESC 关闭 useEffect(() => { if (!isOpen) return const handleEsc = (e: KeyboardEvent) => { @@ -177,7 +101,6 @@ export function SidebarFooter({ return () => document.removeEventListener('keydown', handleEsc) }, [isOpen, closeMenu]) - // 侧边栏状态变化时关闭 useEffect(() => { const showLabelsChanged = prevShowLabelsRef.current !== showLabels prevShowLabelsRef.current = showLabels @@ -193,7 +116,6 @@ export function SidebarFooter({ } }, [showLabels, isOpen, closeMenu]) - // 清理 closeTimeout 防止内存泄漏 useEffect(() => { return () => { if (closeTimeoutIdRef.current) { @@ -203,7 +125,6 @@ export function SidebarFooter({ } }, []) - // 浮动菜单 const floatingMenu = isOpen ? createPortal(
- {/* Context Stats */} -
-
- {t('sidebar.contextUsage')} -
- - {Math.round(stats.contextPercent)}% - - -
-
-
-
-
-
- - {formatTokens(stats.contextUsed)} / {formatTokens(stats.contextLimit)} - - {formatCost(stats.totalCost)} -
-
-
- - {/* Theme Selector */}
{t('sidebar.appearance')} @@ -295,7 +174,6 @@ export function SidebarFooter({
- {/* Menu Items */}
{toggleWideMode && (
- - {/* Connection Status */} -
-
-
- {connectionState} -
, document.body, ) @@ -347,7 +224,6 @@ export function SidebarFooter({ return (
- {/* 状态/设置触发按钮 */}
{floatingMenu} setShareDialogOpen(false)} /> - setContextDialogOpen(false)} - contextLimit={stats.contextLimit} - />
) -} +} \ No newline at end of file diff --git a/src/features/chat/sidebar/draftNewChatSession.ts b/src/features/chat/sidebar/draftNewChatSession.ts new file mode 100644 index 00000000..5c17ac7f --- /dev/null +++ b/src/features/chat/sidebar/draftNewChatSession.ts @@ -0,0 +1,15 @@ +import type { ApiSession } from '../../../api' + +export const DRAFT_NEW_CHAT_SESSION_ID = '__draft_new_chat__' + +export function isDraftNewChatSession(session: Pick | null | undefined) { + return session?.id === DRAFT_NEW_CHAT_SESSION_ID +} + +export function createDraftNewChatSession(directory: string, title: string): ApiSession { + return { + id: DRAFT_NEW_CHAT_SESSION_ID, + title, + directory, + } as ApiSession +} diff --git a/src/features/chat/sidebar/recentWorkspaceDirectories.ts b/src/features/chat/sidebar/recentWorkspaceDirectories.ts new file mode 100644 index 00000000..534eb952 --- /dev/null +++ b/src/features/chat/sidebar/recentWorkspaceDirectories.ts @@ -0,0 +1,80 @@ +import type { GitWorkspaceCatalog } from '../../../hooks/useGitWorkspaceCatalog' +import { getProjectGroupIdentity } from './projectGrouping' +import { getDirectoryName, isSameDirectory, normalizeToForwardSlash } from '../../../utils' + +interface SavedDirectory { + path: string + name?: string +} + +export function collectRecentWorkspaceDirectories( + savedDirectories: SavedDirectory[], + catalog: GitWorkspaceCatalog, +): string[] { + const groups = new Map< + string, + { worktree: string; memberDirectories: string[]; workspaceDirectories?: string[] } + >() + + for (const directory of savedDirectories) { + const normalizedDirectory = normalizeToForwardSlash(directory.path) + const meta = catalog.get(normalizedDirectory) + const { projectId, workspaceDirectories } = getProjectGroupIdentity(normalizedDirectory, meta) + const existing = groups.get(projectId) + + if (existing) { + groups.set(projectId, { + ...existing, + memberDirectories: [...existing.memberDirectories, directory.path], + }) + continue + } + + groups.set(projectId, { + worktree: projectId, + memberDirectories: [directory.path], + workspaceDirectories, + }) + } + + const ordered: string[] = [] + const seen = new Set() + + const pushDirectory = (directory: string) => { + const normalized = normalizeToForwardSlash(directory) + const key = normalized.toLowerCase() + if (seen.has(key)) return + seen.add(key) + ordered.push(normalized) + } + + for (const project of groups.values()) { + if (project.workspaceDirectories && project.workspaceDirectories.length > 1) { + const savedWorkspaceDirectories = project.memberDirectories + .map(directory => normalizeToForwardSlash(directory)) + .filter(directory => project.workspaceDirectories?.some(workspace => isSameDirectory(workspace, directory))) + + const remainingWorkspaceDirectories = project.workspaceDirectories.filter( + workspace => !savedWorkspaceDirectories.some(directory => isSameDirectory(directory, workspace)), + ) + + for (const workspace of [...savedWorkspaceDirectories, ...remainingWorkspaceDirectories]) { + pushDirectory(workspace) + } + continue + } + + pushDirectory(project.worktree) + } + + return ordered +} + +export function getWorkspaceDisplayName(directory: string, catalog: GitWorkspaceCatalog): string { + const normalized = normalizeToForwardSlash(directory) + const meta = catalog.get(normalized) + if (meta?.isGit && isSameDirectory(meta.rootDirectory, normalized)) { + return getDirectoryName(meta.rootDirectory) || meta.rootDirectory + } + return getDirectoryName(normalized) || normalized +} \ No newline at end of file diff --git a/src/features/chat/useRecentWorkspaceDirectories.ts b/src/features/chat/useRecentWorkspaceDirectories.ts new file mode 100644 index 00000000..7e64fed8 --- /dev/null +++ b/src/features/chat/useRecentWorkspaceDirectories.ts @@ -0,0 +1,31 @@ +import { useMemo } from 'react' +import { useDirectory, useGitWorkspaceCatalog } from '../../hooks' +import { isSameDirectory, normalizeToForwardSlash } from '../../utils' +import { collectRecentWorkspaceDirectories } from './sidebar/recentWorkspaceDirectories' + +export function useRecentWorkspaceDirectories(currentDirectory?: string) { + const { savedDirectories } = useDirectory() + const catalogDirectories = useMemo( + () => + Array.from( + new Set( + savedDirectories + .map(directory => normalizeToForwardSlash(directory.path)) + .concat(currentDirectory ? [normalizeToForwardSlash(currentDirectory)] : []), + ), + ), + [savedDirectories, currentDirectory], + ) + const { catalog } = useGitWorkspaceCatalog(catalogDirectories) + + return useMemo(() => { + const directories = collectRecentWorkspaceDirectories(savedDirectories, catalog) + if (currentDirectory) { + const normalizedCurrent = normalizeToForwardSlash(currentDirectory) + if (!directories.some(directory => isSameDirectory(directory, normalizedCurrent))) { + return [normalizedCurrent, ...directories] + } + } + return directories + }, [savedDirectories, catalog, currentDirectory]) +} \ No newline at end of file diff --git a/src/features/chat/useSwitchWorkspaceDirectory.ts b/src/features/chat/useSwitchWorkspaceDirectory.ts new file mode 100644 index 00000000..bdba6441 --- /dev/null +++ b/src/features/chat/useSwitchWorkspaceDirectory.ts @@ -0,0 +1,26 @@ +import { useCallback } from 'react' +import { useDirectory, useRouter } from '../../hooks' +import { paneLayoutStore } from '../../store' +import { normalizeToForwardSlash } from '../../utils' + +export function useSwitchWorkspaceDirectory() { + const { addDirectory, setCurrentDirectory } = useDirectory() + const { setDirectory } = useRouter() + + return useCallback( + (directory: string) => { + const normalized = normalizeToForwardSlash(directory) + addDirectory(normalized) + setCurrentDirectory(normalized) + + const paneId = paneLayoutStore.getFocusedPaneId() + if (paneId) { + paneLayoutStore.focusPane(paneId) + paneLayoutStore.setPaneSession(paneId, null) + } + + setDirectory(normalized) + }, + [addDirectory, setCurrentDirectory, setDirectory], + ) +} \ No newline at end of file diff --git a/src/features/settings/components/AppearanceSettings.tsx b/src/features/settings/components/AppearanceSettings.tsx index 5d06a7f9..44e996c0 100644 --- a/src/features/settings/components/AppearanceSettings.tsx +++ b/src/features/settings/components/AppearanceSettings.tsx @@ -3,6 +3,7 @@ import { Trans, useTranslation } from 'react-i18next' import { Button } from '../../../components/ui/Button' import { SunIcon, MoonIcon, SystemIcon, CheckIcon, ChevronDownIcon } from '../../../components/Icons' import { Toggle, SegmentedControl, SettingRow, SettingsSection } from './SettingsUI' +import { CodeBlockThemeSettings } from './CodeBlockThemeSettings' import { useTheme } from '../../../hooks' import { getThemePreset } from '../../../themes' import type { CustomCSSSnippet } from '../../../store/themeStore' @@ -726,6 +727,8 @@ export function AppearanceSettings() {
+ +
) } diff --git a/src/features/settings/components/CodeBlockThemeSettings.tsx b/src/features/settings/components/CodeBlockThemeSettings.tsx new file mode 100644 index 00000000..930bbf31 --- /dev/null +++ b/src/features/settings/components/CodeBlockThemeSettings.tsx @@ -0,0 +1,193 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { SettingRow, SettingsSection } from './SettingsUI' +import { useTheme } from '../../../hooks' +import { + AVAILABLE_CODE_BLOCK_THEMES, + filterThemesByType, + type CodeBlockThemeInfo, +} from '../../../lib/codeBlockThemes' +import { highlightHtmlInWorker } from '../../../lib/shikiWorkerClient' +import { ChevronDownIcon } from '../../../components/Icons' + +// 共享的预览代码片段:覆盖关键字、字符串、注释、数字、函数调用、属性等常见 token +const PREVIEW_CODE = `// greet user by name +function greet(name: string): string { + const message = \`Hello, \${name}!\` + return message +} + +const result = greet("world") +console.log(result)` + +const PREVIEW_LANGUAGE = 'ts' + +function themeDisplayName(id: string): string { + return AVAILABLE_CODE_BLOCK_THEMES.find(t => t.id === id)?.displayName ?? id +} + +// ============================================ +// Theme select dropdown +// ============================================ + +function CodeBlockThemeSelect({ + value, + onChange, + type, +}: { + value: string + onChange: (id: string) => void + type: 'light' | 'dark' +}) { + // 同 type 的主题作为默认推荐组,其它 type 作为另一组放下面(用户仍可混搭) + const sameType = useMemo(() => filterThemesByType(type), [type]) + const otherType = useMemo(() => filterThemesByType(type === 'light' ? 'dark' : 'light'), [type]) + + return ( +
+ + +
+ ) +} + +// ============================================ +// Live preview using Shiki +// ============================================ + +function CodeBlockPreview({ themeId, label }: { themeId: string; label: string }) { + const [html, setHtml] = useState(null) + const [error, setError] = useState(null) + const requestKeyRef = useRef(0) + + useEffect(() => { + const key = `preview-${themeId}` + const myKey = ++requestKeyRef.current + + let cancelled = false + highlightHtmlInWorker({ + key, + text: PREVIEW_CODE, + language: PREVIEW_LANGUAGE, + theme: themeId as Parameters[0]['theme'], + }) + .then(result => { + if (cancelled || myKey !== requestKeyRef.current) return + setHtml(result.html) + setError(null) + }) + .catch(err => { + if (cancelled || myKey !== requestKeyRef.current) return + setError(err instanceof Error ? err.message : String(err)) + setHtml(null) + }) + + return () => { + cancelled = true + } + }, [themeId]) + + return ( +
+
+

{label}

+

{themeDisplayName(themeId)}

+
+
+ {html ? ( +
自带 inline style (bg/fg/color),直接渲染 + dangerouslySetInnerHTML={{ __html: html }} + /> + ) : error ? ( +
{error}
+ ) : ( +
+            {PREVIEW_CODE}
+          
+ )} +
+
+ ) +} + +// ============================================ +// Main section +// ============================================ + +export function CodeBlockThemeSettings() { + const { t } = useTranslation(['settings', 'common']) + const { + codeBlockThemeLight, + codeBlockThemeDark, + setCodeBlockThemeLight, + setCodeBlockThemeDark, + resolvedTheme, + } = useTheme() + + return ( + +

{t('appearance.codeBlockThemesDesc')}

+ + + + + + + + + +
+ +
+
+ ) +} + +// 导出 unused type 仅用于未来扩展(被引用以避免 tree-shake 误删) +export type { CodeBlockThemeInfo } diff --git a/src/features/settings/components/ServersSettings.test.tsx b/src/features/settings/components/ServersSettings.test.tsx index 1b9df89f..03dd1b0a 100644 --- a/src/features/settings/components/ServersSettings.test.tsx +++ b/src/features/settings/components/ServersSettings.test.tsx @@ -2,10 +2,11 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ServersSettings } from './ServersSettings' -const { useServerStoreMock, navigateHomeMock, clearSessionMock } = vi.hoisted(() => ({ +const { useServerStoreMock, navigateHomeMock, clearSessionMock, isTauriMock } = vi.hoisted(() => ({ useServerStoreMock: vi.fn(), navigateHomeMock: vi.fn(), clearSessionMock: vi.fn(), + isTauriMock: vi.fn(() => false), })) vi.mock('react-i18next', () => ({ @@ -15,6 +16,10 @@ vi.mock('react-i18next', () => ({ }), })) +vi.mock('../../../utils/tauri', () => ({ + isTauri: isTauriMock, +})) + vi.mock('../../../hooks', () => ({ useServerStore: useServerStoreMock, useRouter: () => ({ navigateHome: navigateHomeMock, sessionId: 'session-1' }), @@ -36,6 +41,7 @@ describe('ServersSettings', () => { setActiveServerMock.mockReset() navigateHomeMock.mockReset() clearSessionMock.mockReset() + isTauriMock.mockReturnValue(false) useServerStoreMock.mockReturnValue({ servers: [localServer, remoteServer], activeServer: localServer, @@ -63,4 +69,18 @@ describe('ServersSettings', () => { expect(navigateHomeMock).toHaveBeenCalled() expect(clearSessionMock).toHaveBeenCalledWith('session-1') }) + + it('shows a delete button on the default server when other servers exist', () => { + render() + + expect(screen.getAllByRole('button', { name: 'common:remove' })).toHaveLength(2) + }) + + it('hides the delete button on the default server on Tauri desktop', () => { + isTauriMock.mockReturnValue(true) + + render() + + expect(screen.getAllByRole('button', { name: 'common:remove' })).toHaveLength(1) + }) }) diff --git a/src/features/settings/components/ServersSettings.tsx b/src/features/settings/components/ServersSettings.tsx index c15f12c1..69071a96 100644 --- a/src/features/settings/components/ServersSettings.tsx +++ b/src/features/settings/components/ServersSettings.tsx @@ -14,6 +14,7 @@ import { } from '../../../components/Icons' import { useServerStore, useRouter } from '../../../hooks' import { messageStore } from '../../../store' +import { isTauri } from '../../../utils/tauri' import { SettingsCard } from './SettingsUI' import type { ServerConfig, ServerHealth } from '../../../store/serverStore' @@ -37,6 +38,7 @@ function ServerItem({ server, health, isActive, + canDeleteDefault, onSelect, onDelete, onEdit, @@ -45,6 +47,7 @@ function ServerItem({ server: ServerConfig health: ServerHealth | null isActive: boolean + canDeleteDefault: boolean onSelect: () => void onDelete: () => void onEdit: (updates: { name: string; url: string; username?: string; password?: string }) => void @@ -139,32 +142,32 @@ function ServerItem({ {statusIcon()} {!server.isDefault && ( - <> - - - + + )} + {(!server.isDefault || canDeleteDefault) && ( + )}
@@ -508,6 +511,7 @@ export function ServersSettings() { getHealth, } = useServerStore() const { navigateHome, sessionId: routeSessionId } = useRouter() + const canDeleteDefault = !isTauri() && servers.length > 1 const orderedServers = useMemo(() => { if (!activeServer) return servers const active = servers.find(s => s.id === activeServer.id) @@ -567,6 +571,7 @@ export function ServersSettings() { server={s} health={getHealth(s.id)} isActive={activeServer?.id === s.id} + canDeleteDefault={canDeleteDefault} onSelect={() => handleSelectServer(s.id)} onDelete={() => removeServer(s.id)} onEdit={updates => { diff --git a/src/hooks/useSyntaxHighlight.test.ts b/src/hooks/useSyntaxHighlight.test.ts index 3bd42d5f..062752be 100644 --- a/src/hooks/useSyntaxHighlight.test.ts +++ b/src/hooks/useSyntaxHighlight.test.ts @@ -12,6 +12,16 @@ describe('getShikiTheme', () => { expect(getShikiTheme(true).key).toBe('github-dark-default') expect(getShikiTheme(false).key).toBe('github-light-default') }) + + it('respects user-configured light/dark themes', () => { + expect(getShikiTheme(false, 'one-light', 'one-dark-pro').theme).toBe('one-light') + expect(getShikiTheme(true, 'one-light', 'one-dark-pro').theme).toBe('one-dark-pro') + }) + + it('falls back to GitHub Default when given an unknown theme id', () => { + expect(getShikiTheme(false, 'not-a-real-theme', 'also-fake').theme).toBe('github-light-default') + expect(getShikiTheme(true, 'not-a-real-theme', 'also-fake').theme).toBe('github-dark-default') + }) }) describe('Shiki language metadata', () => { diff --git a/src/hooks/useSyntaxHighlight.ts b/src/hooks/useSyntaxHighlight.ts index e08a97c4..89f31201 100644 --- a/src/hooks/useSyntaxHighlight.ts +++ b/src/hooks/useSyntaxHighlight.ts @@ -1,14 +1,31 @@ -import { useState, useEffect, useMemo, useRef, useId } from 'react' +import { useState, useEffect, useMemo, useRef, useId, useSyncExternalStore } from 'react' import type { ShikiThemeInput } from '../lib/shikiTheme' import { getShikiTheme, useIsDarkMode } from '../lib/shikiTheme' import { disposeShikiWorkerKey, highlightHtmlInWorker, highlightTokensInWorker } from '../lib/shikiWorkerClient' import type { HighlightTokens } from '../lib/highlightTypes' import { normalizeLanguage } from '../utils/languageUtils' import { THEME_SWITCH_DISABLE_MS } from '../constants' +import { themeStore } from '../store/themeStore' export type { HighlightTokens } from '../lib/highlightTypes' export type { ShikiThemeInput } from '../lib/shikiTheme' +// ============================================ +// 代码块主题订阅(仅在 codeBlockThemeLight/Dark 变化时触发 re-render) +// ============================================ + +function codeBlockThemeKey(): string { + const s = themeStore.getState() + return s.codeBlockThemeLight + '|' + s.codeBlockThemeDark +} + +function useCodeBlockThemes(): { light: string; dark: string } { + // 订阅派生字符串,避免其它 appearance 字段变化时让所有代码块重渲染 + useSyncExternalStore(themeStore.subscribe, codeBlockThemeKey) + const s = themeStore.getState() + return { light: s.codeBlockThemeLight, dark: s.codeBlockThemeDark } +} + type IdleWindowApi = { requestIdleCallback?: (callback: () => void, options?: { timeout?: number }) => number cancelIdleCallback?: (id: number) => void @@ -210,11 +227,12 @@ export function useStreamingSyntaxHighlight( const { lang = 'text', theme, enabled = true } = options const normalizedLang = normalizeLanguage(lang) const isDark = useIsDarkMode() + const codeBlockThemes = useCodeBlockThemes() const instanceId = useId() const resolvedTheme = useMemo(() => { if (theme) return { theme, key: theme } - return getShikiTheme(isDark) - }, [theme, isDark]) + return getShikiTheme(isDark, codeBlockThemes.light, codeBlockThemes.dark) + }, [theme, isDark, codeBlockThemes.light, codeBlockThemes.dark]) const [outputState, setOutputState] = useState<{ code: string; tokens: HighlightTokens } | null>(null) const [isLoading, setIsLoading] = useState(false) @@ -295,13 +313,14 @@ export function useSyntaxHighlight(code: string, options: HighlightOptions & { m const normalizedLang = normalizeLanguage(lang) const isDark = useIsDarkMode() + const codeBlockThemes = useCodeBlockThemes() const resolvedTheme = useMemo(() => { if (theme) { return { theme, key: theme } } - return getShikiTheme(isDark) - }, [theme, isDark]) + return getShikiTheme(isDark, codeBlockThemes.light, codeBlockThemes.dark) + }, [theme, isDark, codeBlockThemes.light, codeBlockThemes.dark]) const cacheKey = useMemo( () => getCacheKey(code, normalizedLang, resolvedTheme.key), @@ -406,12 +425,13 @@ export function useSyntaxHighlightRef( const normalizedLang = normalizeLanguage(lang) const isDark = useIsDarkMode() + const codeBlockThemes = useCodeBlockThemes() const resolvedTheme = useMemo(() => { if (theme) { return { theme, key: theme } } - return getShikiTheme(isDark) - }, [theme, isDark]) + return getShikiTheme(isDark, codeBlockThemes.light, codeBlockThemes.dark) + }, [theme, isDark, codeBlockThemes.light, codeBlockThemes.dark]) const tokensRef = useRef(null) const [version, setVersion] = useState(0) diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts index cc30fbbd..97301aa2 100644 --- a/src/hooks/useTheme.ts +++ b/src/hooks/useTheme.ts @@ -238,6 +238,14 @@ export function useTheme() { themeStore.setProcessCollapseEnabled(enabled) }, []) + const setCodeBlockThemeLight = useCallback((id: string) => { + themeStore.setCodeBlockThemeLight(id) + }, []) + + const setCodeBlockThemeDark = useCallback((id: string) => { + themeStore.setCodeBlockThemeDark(id) + }, []) + return { // 日夜模式(向后兼容) mode: state.colorMode, @@ -352,5 +360,11 @@ export function useTheme() { // 过程折叠 processCollapseEnabled: state.processCollapseEnabled, setProcessCollapseEnabled, + + // 代码块主题(Shiki) + codeBlockThemeLight: state.codeBlockThemeLight, + codeBlockThemeDark: state.codeBlockThemeDark, + setCodeBlockThemeLight, + setCodeBlockThemeDark, } } diff --git a/src/lib/codeBlockThemes.ts b/src/lib/codeBlockThemes.ts new file mode 100644 index 00000000..39fb7012 --- /dev/null +++ b/src/lib/codeBlockThemes.ts @@ -0,0 +1,38 @@ +/** + * Code block (Shiki) theme catalog + helpers. + * + * `bundledThemesInfo` 来自 `shiki/themes`,包含全部 65 个内置 Shiki 主题的 + * `{ id, displayName, type }` 元数据。Worker 端使用同源的 lazy `import` 字段 + * 按需加载,主线程只读元数据用于下拉菜单。 + */ + +import { bundledThemesInfo } from 'shiki/themes' +import type { BundledTheme } from 'shiki/themes' + +export type ShikiThemeType = 'light' | 'dark' + +export interface CodeBlockThemeInfo { + id: BundledTheme + displayName: string + type: ShikiThemeType +} + +/** 全部 Shiki 内置主题元数据,按 displayName 字母序排序 */ +export const AVAILABLE_CODE_BLOCK_THEMES: readonly CodeBlockThemeInfo[] = bundledThemesInfo + .map(t => ({ id: t.id as BundledTheme, displayName: t.displayName, type: t.type as ShikiThemeType })) + .sort((a, b) => a.displayName.localeCompare(b.displayName)) + +export const DEFAULT_CODE_BLOCK_THEME_LIGHT = 'github-light-default' as const +export const DEFAULT_CODE_BLOCK_THEME_DARK = 'github-dark-default' as const + +const knownIds = new Set(AVAILABLE_CODE_BLOCK_THEMES.map(t => t.id)) + +/** 校验 Shiki theme id 是否存在;不存在则回退到对应默认值 */ +export function normalizeCodeBlockTheme(id: string, fallback: BundledTheme): BundledTheme { + return knownIds.has(id) ? (id as BundledTheme) : fallback +} + +/** 按 type 过滤(light/dark) */ +export function filterThemesByType(type: ShikiThemeType): readonly CodeBlockThemeInfo[] { + return AVAILABLE_CODE_BLOCK_THEMES.filter(t => t.type === type) +} diff --git a/src/lib/shikiTheme.ts b/src/lib/shikiTheme.ts index aaa05fa9..f4183d0b 100644 --- a/src/lib/shikiTheme.ts +++ b/src/lib/shikiTheme.ts @@ -1,10 +1,25 @@ import { useState, useEffect } from 'react' import type { BundledTheme } from 'shiki/themes' +import { + DEFAULT_CODE_BLOCK_THEME_DARK, + DEFAULT_CODE_BLOCK_THEME_LIGHT, + normalizeCodeBlockTheme, +} from './codeBlockThemes' export type ShikiThemeInput = BundledTheme -export function getShikiTheme(isDark: boolean): { theme: ShikiThemeInput; key: string } { - const theme = isDark ? 'github-dark-default' : 'github-light-default' +/** + * 根据 isDark + 用户在设置里选择的代码块主题解析出实际使用的 Shiki 主题。 + * 入参为空字符串/无效值时回退到 GitHub Default。 + */ +export function getShikiTheme( + isDark: boolean, + codeBlockThemeLight: string = DEFAULT_CODE_BLOCK_THEME_LIGHT, + codeBlockThemeDark: string = DEFAULT_CODE_BLOCK_THEME_DARK, +): { theme: ShikiThemeInput; key: string } { + const fallback = isDark ? DEFAULT_CODE_BLOCK_THEME_DARK : DEFAULT_CODE_BLOCK_THEME_LIGHT + const requested = isDark ? codeBlockThemeDark : codeBlockThemeLight + const theme = normalizeCodeBlockTheme(requested, fallback) return { theme, key: theme } } diff --git a/src/lib/shikiWorkerClient.ts b/src/lib/shikiWorkerClient.ts index c3f734f6..f050628c 100644 --- a/src/lib/shikiWorkerClient.ts +++ b/src/lib/shikiWorkerClient.ts @@ -1,6 +1,11 @@ import type { BundledTheme } from 'shiki/themes' import type { WorkerRequest, WorkerResponse, WorkerToken } from '../workers/shikiWorker' import type { HighlightTokens } from './highlightTypes' +import { + DEFAULT_CODE_BLOCK_THEME_DARK, + DEFAULT_CODE_BLOCK_THEME_LIGHT, + normalizeCodeBlockTheme, +} from './codeBlockThemes' type PendingRequest = { resolve: (response: WorkerResponse) => void @@ -73,7 +78,18 @@ export function ensureShikiWorkerReady(): Promise { workerReadyPromiseResolve = resolve workerReadyPromiseReject = reject }) - getWorker().postMessage({ type: 'init', themes: ['github-dark-default', 'github-light-default'] } satisfies WorkerRequest) + // 预加载用户当前选择的主题;其他主题在第一次 highlight 时 lazy load。 + // 用 localStorage 直接读避免循环依赖(themeStore 也会反向引用此模块树)。 + const light = normalizeCodeBlockTheme( + typeof localStorage !== 'undefined' && localStorage.getItem('code-block-theme-light') || DEFAULT_CODE_BLOCK_THEME_LIGHT, + DEFAULT_CODE_BLOCK_THEME_LIGHT, + ) + const dark = normalizeCodeBlockTheme( + typeof localStorage !== 'undefined' && localStorage.getItem('code-block-theme-dark') || DEFAULT_CODE_BLOCK_THEME_DARK, + DEFAULT_CODE_BLOCK_THEME_DARK, + ) + const themes = Array.from(new Set([light, dark])) + getWorker().postMessage({ type: 'init', themes } satisfies WorkerRequest) return workerReady } diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 83e09c9b..4a8910c6 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -202,6 +202,14 @@ "uiFontScaleDesc": "Adjust the font size for menus, labels, and other UI elements", "codeFontScale": "Code Font Size", "codeFontScaleDesc": "Adjust the font size for code blocks, diffs, and terminal", + "codeBlockThemes": "Code Block Theme", + "codeBlockThemesDesc": "Pick the syntax highlighting theme used in fenced code blocks. Light and dark are configured independently and switch with your color mode.", + "codeBlockThemeLight": "Light Mode Code Block", + "codeBlockThemeLightDesc": "Syntax theme used when the color mode resolves to light", + "codeBlockThemeDark": "Dark Mode Code Block", + "codeBlockThemeDarkDesc": "Syntax theme used when the color mode resolves to dark", + "codeBlockPreviewLight": "Light preview", + "codeBlockPreviewDark": "Dark preview", "fontScaleReset": "Reset to default", "codeWordWrap": "Code Word Wrap", "codeWordWrapDesc": "Wrap code blocks and diffs to the available width to reduce horizontal scrolling", diff --git a/src/locales/zh-CN/settings.json b/src/locales/zh-CN/settings.json index 2153983e..0ce073b6 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -202,6 +202,14 @@ "uiFontScaleDesc": "调整菜单、标签等 UI 元素的字体大小", "codeFontScale": "代码字号", "codeFontScaleDesc": "调整代码块、差异对比和终端的字体大小", + "codeBlockThemes": "代码块主题", + "codeBlockThemesDesc": "选择 fenced 代码块使用的语法高亮主题。亮色和暗色可分别设置,并随颜色模式自动切换。", + "codeBlockThemeLight": "亮色模式代码块", + "codeBlockThemeLightDesc": "颜色模式为亮色时使用的语法主题", + "codeBlockThemeDark": "暗色模式代码块", + "codeBlockThemeDarkDesc": "颜色模式为暗色时使用的语法主题", + "codeBlockPreviewLight": "亮色预览", + "codeBlockPreviewDark": "暗色预览", "fontScaleReset": "恢复默认", "codeWordWrap": "代码自动换行", "codeWordWrapDesc": "让代码块和 diff 在可用宽度内自动换行,减少横向滚动", diff --git a/src/store/serverStore.test.ts b/src/store/serverStore.test.ts index e5000800..72e851ef 100644 --- a/src/store/serverStore.test.ts +++ b/src/store/serverStore.test.ts @@ -70,6 +70,51 @@ describe('serverStore clock calibration', () => { }) }) +describe('serverStore removeServer', () => { + beforeEach(() => { + vi.resetModules() + localStorage.clear() + sessionStorage.clear() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('removes the default server when other servers exist', async () => { + const { serverStore } = await import('./serverStore') + serverStore.addServer({ name: 'Remote', url: 'http://remote.test' }) + + expect(serverStore.removeServer('local')).toBe(true) + expect(serverStore.getStoredServers().some(s => s.id === 'local')).toBe(false) + }) + + it('switches the active server when removing the active default', async () => { + const { serverStore } = await import('./serverStore') + const remote = serverStore.addServer({ name: 'Remote', url: 'http://remote.test' }) + + serverStore.removeServer('local') + + expect(serverStore.getActiveServerId()).toBe(remote.id) + }) + + it('refuses to remove the default server when it is the only one', async () => { + const { serverStore } = await import('./serverStore') + + expect(serverStore.removeServer('local')).toBe(false) + expect(serverStore.getStoredServers().some(s => s.id === 'local')).toBe(true) + }) + + it('refuses to remove the default server on Tauri desktop', async () => { + vi.stubGlobal('__TAURI_INTERNALS__', { invoke: vi.fn() }) + const { serverStore } = await import('./serverStore') + serverStore.addServer({ name: 'Remote', url: 'http://remote.test' }) + + expect(serverStore.removeServer('local')).toBe(false) + expect(serverStore.getStoredServers().some(s => s.id === 'local')).toBe(true) + }) +}) + describe('serverStore local runtime URL', () => { beforeEach(() => { vi.resetModules() diff --git a/src/store/serverStore.ts b/src/store/serverStore.ts index ed1f57c3..debec609 100644 --- a/src/store/serverStore.ts +++ b/src/store/serverStore.ts @@ -414,9 +414,10 @@ class ServerStore { * 删除服务器 */ removeServer(id: string): boolean { - // 不能删除默认服务器 const server = this.servers.find(s => s.id === id) - if (!server || server.isDefault) return false + if (!server) return false + // 默认服务器仅在有其他服务器且非桌面端时可删除 + if (server.isDefault && (isTauri() || this.servers.length <= 1)) return false this.servers = this.servers.filter(s => s.id !== id) this.healthMap.delete(id) diff --git a/src/store/themeStore.ts b/src/store/themeStore.ts index 18494377..b34c7921 100644 --- a/src/store/themeStore.ts +++ b/src/store/themeStore.ts @@ -128,6 +128,9 @@ const DEFAULT_COMPACT_INLINE_PERMISSION = false const DEFAULT_GLASS_EFFECT = true const DEFAULT_QUEUE_FOLLOWUP_MESSAGES = false const DEFAULT_MANUAL_TERMINAL_TITLES = false +/** Shiki 代码块主题:默认 GitHub,保留现有行为 */ +const DEFAULT_CODE_BLOCK_THEME_LIGHT = 'github-light-default' +const DEFAULT_CODE_BLOCK_THEME_DARK = 'github-dark-default' const DEFAULT_EXTERNAL_FILE_DROP_MODE: ExternalFileDropMode = 'upload-first' const DEFAULT_OUTLINE_CURRENT_HIGHLIGHT = true /** 连续助手消息时,仅在回合末尾显示分叉/复制按钮 */ @@ -194,6 +197,10 @@ export interface ThemeState { desktopCollapsedInputDock: boolean /** 过程折叠:用户发送后显示 Working 计时,结束后收成折叠块,最终回答留在外面 */ processCollapseEnabled: boolean + /** 代码块语法高亮主题(亮色模式),Shiki theme id */ + codeBlockThemeLight: string + /** 代码块语法高亮主题(暗色模式),Shiki theme id */ + codeBlockThemeDark: string } export type ThemeBackup = ThemeState @@ -230,6 +237,8 @@ const STORAGE_KEY_OUTLINE_CURRENT_HIGHLIGHT = 'outline-current-highlight' const STORAGE_KEY_ACTIONS_ON_LATEST_ASSISTANT_ONLY = 'actions-on-latest-assistant-only' const STORAGE_KEY_DESKTOP_COLLAPSED_INPUT_DOCK = 'desktop-collapsed-input-dock' const STORAGE_KEY_PROCESS_COLLAPSE_ENABLED = 'process-collapse-enabled' +const STORAGE_KEY_CODE_BLOCK_THEME_LIGHT = 'code-block-theme-light' +const STORAGE_KEY_CODE_BLOCK_THEME_DARK = 'code-block-theme-dark' // ============================================ // DOM Style Element IDs @@ -377,6 +386,11 @@ class ThemeStore { ? DEFAULT_PROCESS_COLLAPSE_ENABLED : savedProcessCollapseEnabled === 'true' + const savedCodeBlockThemeLight = localStorage.getItem(STORAGE_KEY_CODE_BLOCK_THEME_LIGHT) + const codeBlockThemeLight = savedCodeBlockThemeLight || DEFAULT_CODE_BLOCK_THEME_LIGHT + const savedCodeBlockThemeDark = localStorage.getItem(STORAGE_KEY_CODE_BLOCK_THEME_DARK) + const codeBlockThemeDark = savedCodeBlockThemeDark || DEFAULT_CODE_BLOCK_THEME_DARK + this.state = { presetId: normalizedPreset, colorMode: savedMode, @@ -406,6 +420,8 @@ class ThemeStore { actionsOnLatestAssistantOnly, desktopCollapsedInputDock, processCollapseEnabled, + codeBlockThemeLight, + codeBlockThemeDark, } } @@ -503,6 +519,14 @@ class ThemeStore { return this.state.processCollapseEnabled } + get codeBlockThemeLight() { + return this.state.codeBlockThemeLight + } + + get codeBlockThemeDark() { + return this.state.codeBlockThemeDark + } + /** 获取当前主题预设(内置主题返回对象,自定义返回 undefined) */ getPreset(): ThemePreset | undefined { return getThemePreset(this.state.presetId) @@ -804,6 +828,20 @@ class ThemeStore { this.emit() } + setCodeBlockThemeLight(id: string) { + if (this.state.codeBlockThemeLight === id) return + this.state = { ...this.state, codeBlockThemeLight: id } + localStorage.setItem(STORAGE_KEY_CODE_BLOCK_THEME_LIGHT, id) + this.emit() + } + + setCodeBlockThemeDark(id: string) { + if (this.state.codeBlockThemeDark === id) return + this.state = { ...this.state, codeBlockThemeDark: id } + localStorage.setItem(STORAGE_KEY_CODE_BLOCK_THEME_DARK, id) + this.emit() + } + // ---- Theme Application ---- /** 初始化:应用当前主题到 DOM */ @@ -1063,6 +1101,14 @@ function normalizeThemeBackup(raw: unknown): ThemeBackup { typeof parsed?.processCollapseEnabled === 'boolean' ? parsed.processCollapseEnabled : DEFAULT_PROCESS_COLLAPSE_ENABLED, + codeBlockThemeLight: + typeof parsed?.codeBlockThemeLight === 'string' && parsed.codeBlockThemeLight + ? parsed.codeBlockThemeLight + : DEFAULT_CODE_BLOCK_THEME_LIGHT, + codeBlockThemeDark: + typeof parsed?.codeBlockThemeDark === 'string' && parsed.codeBlockThemeDark + ? parsed.codeBlockThemeDark + : DEFAULT_CODE_BLOCK_THEME_DARK, } } @@ -1112,4 +1158,6 @@ export function importThemeBackup(raw: unknown): void { ) localStorage.setItem(STORAGE_KEY_DESKTOP_COLLAPSED_INPUT_DOCK, String(backup.desktopCollapsedInputDock)) localStorage.setItem(STORAGE_KEY_PROCESS_COLLAPSE_ENABLED, String(backup.processCollapseEnabled)) + localStorage.setItem(STORAGE_KEY_CODE_BLOCK_THEME_LIGHT, backup.codeBlockThemeLight) + localStorage.setItem(STORAGE_KEY_CODE_BLOCK_THEME_DARK, backup.codeBlockThemeDark) } diff --git a/src/themes/index.ts b/src/themes/index.ts index e9ce3bdc..0a78fb2d 100644 --- a/src/themes/index.ts +++ b/src/themes/index.ts @@ -900,6 +900,246 @@ export const obsidianTheme: ThemePreset = { dark: obsidianDark, } +// ============================================ +// GitHub 主题 - Primer 设计系统 +// ============================================ +// 颜色来源:primer/primitives + primer/github-vscode-theme +// - Light canvas/inset #ffffff / #f6f8fa,accent #0969da,fg #1f2328 +// - Dark canvas #0d1117 / surface #21262d,accent #2f81f7,fg #e6edf3 + +const githubLight: ThemeColors = { + background: { + bg000: '0 0% 100%', // canvas.default #ffffff + bg100: '210 29% 97%', // canvas.inset #f6f8fa + bg200: '210 24% 93%', // neutral.subtle #eaeef2 + bg300: '210 18% 84%', // border.default #d0d7de + bg400: '210 13% 72%', // neutral.emphasis #afb8c1 + }, + text: { + text000: '0 0% 100%', + text100: '213 13% 14%', // fg.default #1f2328 + text200: '212 8% 43%', // fg.muted #656d76 + text300: '212 8% 47%', // fg.subtle #6e7781 + text400: '215 8% 62%', // fg.disabled #8c959f + text500: '215 8% 75%', + text600: '215 8% 84%', + }, + accent: { + brand: '212 92% 45%', // accent.fg #0969da + main000: '212 90% 40%', + main100: '212 92% 45%', + main200: '211 100% 56%', // accent.bright #218bff + secondary100: '261 69% 59%', // done.fg #8250df + }, + semantic: { + success100: '137 66% 30%', // success.fg #1a7f37 + success200: '137 63% 25%', + successBg: '133 80% 92%', // success.subtle #dafbe1 + warning100: '40 100% 30%', // attention.fg #9a6700 + warning200: '41 95% 25%', + warningBg: '53 100% 89%', // attention.subtle #fff8c5 + danger000: '356 72% 47%', // danger.fg #cf222e + danger100: '356 72% 47%', + danger200: '356 70% 40%', + dangerBg: '5 100% 96%', // danger.subtle #ffebe9 + danger900: '5 80% 92%', + info100: '212 92% 45%', + info200: '211 100% 56%', + infoBg: '199 100% 93%', // accent.subtle #ddf4ff + }, + border: { + border100: '210 18% 84%', // border.default #d0d7de + border200: '210 18% 87%', // border.muted #d8dee4 + border300: '210 13% 72%', // neutral #afb8c1 + }, + special: { + alwaysBlack: '0 0% 0%', + alwaysWhite: '0 0% 100%', + oncolor100: '0 0% 100%', + }, +} + +const githubDark: ThemeColors = { + background: { + bg000: '215 15% 15%', // surface #21262d + bg100: '215 21% 11%', // canvas.subtle #161b22 + bg200: '216 28% 7%', // canvas.default #0d1117 + bg300: '218 80% 4%', + bg400: '218 80% 2%', // canvas.inset #010409 + }, + text: { + text000: '0 0% 100%', + text100: '208 35% 93%', // fg.default #e6edf3 + text200: '215 8% 53%', // fg.muted #7d8590 + text300: '215 8% 43%', + text400: '215 7% 35%', + text500: '215 6% 28%', + text600: '215 5% 22%', + }, + accent: { + brand: '215 93% 58%', // accent.fg #2f81f7 + main000: '216 84% 52%', // accent.emphasis #1f6feb + main100: '215 93% 58%', + main200: '215 93% 67%', + secondary100: '262 89% 71%', // done.fg #a371f7 + }, + semantic: { + success100: '128 49% 49%', // success.fg #3fb950 + success200: '128 50% 60%', + successBg: '141 90% 12%', // success.subtle #033a16 + warning100: '41 72% 48%', // attention.fg #d29922 + warning200: '41 75% 58%', + warningBg: '29 34% 17%', // attention.subtle + danger000: '3 93% 63%', // danger.fg #f85149 + danger100: '3 90% 60%', + danger200: '3 90% 68%', + dangerBg: '352 70% 17%', // danger.subtle + danger900: '352 60% 25%', + info100: '215 93% 58%', + info200: '215 93% 68%', + infoBg: '219 80% 23%', // accent.subtle + }, + border: { + border100: '212 12% 21%', // border.default #30363d + border200: '215 15% 15%', // border.muted #21262d + border300: '212 12% 30%', + }, + special: { + alwaysBlack: '0 0% 0%', + alwaysWhite: '0 0% 100%', + oncolor100: '0 0% 100%', + }, +} + +export const githubTheme: ThemePreset = { + id: 'github', + name: 'GitHub', + description: 'Primer palette, signature GitHub blue on neutral canvas', + light: githubLight, + dark: githubDark, +} + +// ============================================ +// One 主题 - Atom One Dark / Light +// ============================================ +// 颜色来源:base16 one-light-scheme + onedark (verbatim base00–base0F) +// - Light #fafafa 底,accent = base0B string green #50a14f(也是 inline code 色),fg #383a42 +// - Dark #282c34 底,accent = base0B string green #98c379,fg #abb2bf +// 注:OpenCodeUI 把 brand 与 inline code 都绑在 accent.main100 上,所以选了 Atom +// 最具辨识度的 string green 当 brand;secondary 退回 base0D blue(function hue)。 + +const oneLight: ThemeColors = { + background: { + bg000: '0 0% 100%', + bg100: '0 0% 98%', // base00 #fafafa + bg200: '240 3% 94%', // base01 #f0f0f1 + bg300: '240 2% 90%', // base02 #e5e5e6 + bg400: '231 4% 80%', + }, + text: { + text000: '0 0% 100%', + text100: '228 8% 24%', // base05 #383a42 + text200: '227 6% 44%', // base04 #696c77 + text300: '231 4% 64%', // base03 #a0a1a7 + text400: '230 4% 75%', + text500: '230 3% 85%', + text600: '230 3% 90%', + }, + accent: { + brand: '119 34% 47%', // base0B #50a14f (string green) + main000: '119 35% 38%', + main100: '119 34% 47%', + main200: '119 38% 55%', + secondary100: '221 87% 60%', // base0D #4078f2 (function blue) + }, + semantic: { + success100: '119 34% 47%', // base0B #50a14f + success200: '119 35% 35%', + successBg: '120 30% 92%', + warning100: '41 99% 38%', // base0A #c18401 + warning200: '41 90% 30%', + warningBg: '42 100% 92%', + danger000: '344 84% 43%', // base08 #ca1243 + danger100: '344 84% 43%', + danger200: '344 80% 50%', + dangerBg: '345 70% 95%', + danger900: '345 50% 92%', + info100: '198 99% 37%', // base0C #0184bc + info200: '198 90% 47%', + infoBg: '199 80% 95%', + }, + border: { + border100: '240 2% 90%', // base02 #e5e5e6 + border200: '240 3% 94%', // base01 #f0f0f1 + border300: '228 8% 80%', + }, + special: { + alwaysBlack: '0 0% 0%', + alwaysWhite: '0 0% 100%', + oncolor100: '0 0% 100%', + }, +} + +const oneDark: ThemeColors = { + background: { + bg000: '218 13% 24%', // base01 #353b45 + bg100: '220 13% 18%', // base00 #282c34 + bg200: '220 14% 14%', + bg300: '220 14% 10%', + bg400: '220 14% 6%', + }, + text: { + text000: '0 0% 100%', + text100: '219 14% 71%', // base05 #abb2bf + text200: '219 12% 60%', + text300: '219 10% 50%', + text400: '223 8% 38%', // base04 #565c64 + text500: '220 6% 30%', + text600: '220 5% 22%', + }, + accent: { + brand: '95 38% 62%', // base0B #98c379 (string green) + main000: '95 38% 52%', + main100: '95 38% 62%', + main200: '95 42% 70%', + secondary100: '207 82% 66%', // base0D #61afef (function blue) + }, + semantic: { + success100: '95 38% 62%', // base0B #98c379 + success200: '95 35% 70%', + successBg: '95 30% 15%', + warning100: '39 67% 69%', // base0A #e5c07b + warning200: '39 65% 75%', + warningBg: '39 30% 15%', + danger000: '355 65% 65%', // base08 #e06c75 + danger100: '355 65% 65%', + danger200: '355 60% 72%', + dangerBg: '355 40% 15%', + danger900: '355 30% 22%', + info100: '187 47% 55%', // base0C #56b6c2 + info200: '187 50% 62%', + infoBg: '187 40% 15%', + }, + border: { + border100: '220 14% 24%', + border200: '220 13% 20%', + border300: '218 13% 28%', // base02 #3e4451 + }, + special: { + alwaysBlack: '0 0% 0%', + alwaysWhite: '0 0% 100%', + oncolor100: '0 0% 100%', + }, +} + +export const oneTheme: ThemePreset = { + id: 'one', + name: 'One', + description: 'Atom One palette, string-green accent with five-color syntax tones', + light: oneLight, + dark: oneDark, +} + // ============================================ // Theme Registry // ============================================ @@ -912,6 +1152,8 @@ export const builtinThemes: ThemePreset[] = [ oceanTheme, draculaTheme, obsidianTheme, + githubTheme, + oneTheme, ] export function getThemePreset(id: string): ThemePreset | undefined { diff --git a/src/workers/shikiWorker.ts b/src/workers/shikiWorker.ts index 751957e9..67236ad4 100644 --- a/src/workers/shikiWorker.ts +++ b/src/workers/shikiWorker.ts @@ -9,7 +9,7 @@ import { import { createOnigurumaEngine } from 'shiki/engine/oniguruma' import onigWasmUrl from 'shiki/onig.wasm?url' import { bundledLanguagesAlias, bundledLanguagesBase } from 'shiki/langs' -import type { BundledTheme } from 'shiki/themes' +import { bundledThemesInfo, type BundledTheme } from 'shiki/themes' export type WorkerToken = [content: string, color: string] @@ -82,6 +82,38 @@ async function ensureLang(instance: HighlighterCore, lang: string): Promise")` 静态字面量),让 Vite 为每个主题 + * 生成独立 chunk,按需 lazy-load。预加载过的主题记录在 loadedThemes 里。 + */ +const themeImporters = new Map Promise<{ default: unknown }>>( + bundledThemesInfo.map(t => [t.id, t.import as () => Promise<{ default: unknown }>]), +) +const loadedThemes = new Set() +const pendingThemeLoads = new Map>() + +async function ensureTheme(instance: HighlighterCore, theme: string): Promise { + if (loadedThemes.has(theme)) return + const existing = pendingThemeLoads.get(theme) + if (existing) return existing + + const importer = themeImporters.get(theme) + if (!importer) throw new Error(`Unknown Shiki theme: ${theme}`) + + const promise = (async () => { + const mod = await importer() + await instance.loadTheme(mod.default as Parameters[0]) + loadedThemes.add(theme) + })() + pendingThemeLoads.set(theme, promise) + try { + await promise + } finally { + pendingThemeLoads.delete(theme) + } +} + function toWorkerToken(value: ThemedToken): WorkerToken { return [value.content, value.color ?? ''] } @@ -104,6 +136,9 @@ async function highlight(request: Extract) const instance = await highlighter if (!instance) throw new Error('Shiki worker not initialized') + // 主题按需加载(init 时只预加载了用户当前选择;切换主题后第一次 highlight 触发 lazy load) + await ensureTheme(instance, request.theme) + const requestedLanguage = request.language.toLowerCase() const language = plainLanguages.has(requestedLanguage) || findLangLoader(requestedLanguage) ? requestedLanguage : 'text' const isPlainText = plainLanguages.has(language) @@ -216,13 +251,26 @@ const themeLoaders: Record Promise> = { self.onmessage = (event: MessageEvent) => { const msg = event.data if (msg.type === 'init') { + // 用 bundledThemesInfo 解析 init 传入的主题 id(来自用户当前选择),用静态字面量 + // import 让 Vite 把它们打成独立 chunk。未知 id 回退到 github-dark-default。 + const resolvedThemeSpecs = msg.themes.map(t => { + const info = bundledThemesInfo.find(b => b.id === t) + return info ? info.import : themeLoaders['github-dark-default']! + }) highlighter ??= createHighlighterCore({ engine: createOnigurumaEngine(loadOnigWasm), - themes: msg.themes.map(t => themeLoaders[t]?.() ?? themeLoaders['github-dark-default']!()) as Parameters[0]['themes'], + themes: resolvedThemeSpecs as Parameters[0]['themes'], langs: [], }) void highlighter - .then(() => post({ type: 'ready' })) + .then(async instance => { + // 标记 init 预加载的主题为已加载,避免重复 ensureTheme + msg.themes.forEach(t => { + if (bundledThemesInfo.some(b => b.id === t)) loadedThemes.add(t) + }) + await instance + post({ type: 'ready' }) + }) .catch(error => post({ type: 'init-error', message: error instanceof Error ? error.message : String(error) })) return } From 4a897f255582b9aca46727d63c3c3f78a63ebfd9 Mon Sep 17 00:00:00 2001 From: Louis LUO Date: Tue, 21 Jul 2026 12:40:51 +0800 Subject: [PATCH 02/13] refactor(chat): input-event-driven scroll-follow redesign Replace the markAuto/isAuto timestamp token system with input-event- driven attribution: userScrolled is now set ONLY by direct user input events, NEVER by scroll events alone. Align nested-scroll handling with the upstream OpenCode boundary model so scrolling inside a code block or diff stays private to that block. Core architecture ---------------- - userScrolled is a single ref + state. scroll events never clear it. - Recovery only via (a) forceScrollToBottom button, (b) explicit down input + atBottom check, or (c) a 500ms recovery window opened by an explicit down input that handleScroll closes on atBottom (iOS momentum / touch-with-px-error edge cases). - handleScroll only schedules a recoverPin rAF when the user is following and scrollTop has drifted off bottom. - stopFollow explicitly closes any active recovery window so a re-stop can't be silently undone by a lingering window. Input attribution ----------------- Inputs are captured at the event source, not reverse-engineered from scroll events: wheel-up target in chat root, not editable, no nested boundary -> stopFollow wheel-down target in chat root, not editable, no nested boundary -> open recovery window wheel target inside marked nested scrollable, not at boundary -> no-op for chat follow wheel nested scrollable already at top (up) / bottom (down) -> escalate to chat gesture touchstart target in chat root, not editable -> stopFollow + reset touchMaxDownRef touchmove update touchMaxDownRef (max downward displacement) touchend touchMaxDownRef > 10 -> tryRecover, else no-op pointerdown on scrollbar region -> stopFollow pointerup no-op (mouse-up is not a "scroll down" gesture) keydown PageUp/Home/ArrowUp -> stopFollow; PageDown/End/ArrowDown -> tryRecover selectionchange empty to non-empty (chat-root anchor) -> stopFollow; clear -> no-op OS_DRAG_START overlayScrollbar custom event -> stopFollow OS_DRAG_END overlayScrollbar custom event -> tryRecover Nested scroll boundaries (upstream alignment) --------------------------------------------- Nested vertical scroll viewports (ScrollArea, CodeBlock, DiffViewer, Markdown code preview) are explicitly marked with data-scrollable. shouldMarkBoundaryGesture() lets a wheel intent escape to the chat only when the nested viewport would overflow its top or bottom, matching OpenCode's markBoundaryGesture. This keeps scrolling inside a code block private to that block; only an overflow attempt becomes an outer chat gesture. normalizeWheelDelta handles pixel / line / page delta modes uniformly. Disclosure widget semantics ---------------------------- useDisclosureScrollLock.withScrollLock(action, expanding) takes an explicit second arg: expanding=true stops the follow (user wants to read expanded content); expanding=false is a no-op (collapsing already- seen content should not change follow state). All 8 callsites updated. AutoScrollContext wires disclosure widgets deep in the message tree to useAutoScroll.pause via context. Implementation details ----------------------- - Listeners attached via AbortController for one-line cleanup. - Handlers + attach split into module-level createInputHandlers / attachInputListeners for readability. - setScrolled short-circuits when the value is unchanged. - scrollEl.style.overflowAnchor='none' set on attach. - toBottom button visibility is driven by userScrolled (via onFollowingChange callback from ChatArea to ChatPane), NOT by a positional threshold, so a tiny wheel-up that doesn't move scroll position still shows the recovery affordance. Files ----- - useAutoScroll.ts rewritten (320 lines): core state machine + handler factory - AutoScrollContext.ts (new): provider + useAutoScrollIntent hook - useDisclosureScrollLock.ts: withScrollLock gains 'expanding' arg - ChatArea.tsx, ChatPane.tsx: integrate new system, expose isFollowing - CodeBlock.tsx, DiffViewer.tsx, MarkdownRenderer.tsx, ScrollArea.tsx: mark vertical scroll viewports with data-scrollable - 8 disclosure widget files (ToolPartView, ReasoningPartView, SubtaskPartView, SystemPartViews, MessageErrorView, TaskRenderer, TodoRenderer, MessageRenderer): pass expanding arg - overlayScrollbar.ts: emit OS_DRAG_START/END custom events from thumb drag - DESIGN.md (new): state machine, input mapping table, recovery rules, sequence diagrams for key scenarios - useAutoScroll.test.tsx (new, 29 tests): wheel, touch, keyboard, pointer, OS_DRAG, recovery window, nested scrollable boundary, rapid events, alternating sequence, editables, handleScroll invariants Behavioral fixes (vs previous markAuto design) ----------------------------------------------- - Layout clamp / viewport resize / find-in-page / history restoration no longer mis-detected as user scroll (no input event matches). - Disclosure toggle no longer causes spurious follow break (lockScrollAroundAnchor scroll writes no longer collide with state inference). - Tiny wheel-up persists stop instead of being immediately undone. - Selection clear no longer changes follow state. - Touch tap does not snap-recover; only real downward drag releases. - Custom scrollbar thumb drag works (OS_DRAG events). - Wheel inside a nested code block stays private until boundary. Testing ------- 608/608 tests pass (29 new in useAutoScroll.test.tsx). tsc -b clean. ESLint clean for the touched areas. --- src/components/CodeBlock.tsx | 4 +- src/components/DiffViewer.tsx | 4 + src/components/MarkdownRenderer.tsx | 2 +- src/components/ui/ScrollArea.tsx | 1 + src/features/chat/ChatArea.tsx | 62 ++- src/features/chat/ChatPane.tsx | 5 +- src/features/chat/DESIGN.md | 250 +++++++++ .../chat/virtual/useAutoScroll.test.tsx | 524 ++++++++++++++++++ src/features/chat/virtual/useAutoScroll.ts | 392 +++++++++---- src/features/message/MessageRenderer.tsx | 8 +- .../message/parts/MessageErrorView.tsx | 2 +- .../message/parts/ReasoningPartView.tsx | 2 +- .../message/parts/SubtaskPartView.tsx | 2 +- .../message/parts/SystemPartViews.tsx | 4 +- src/features/message/parts/ToolPartView.tsx | 2 +- .../message/tools/renderers/TaskRenderer.tsx | 2 +- .../message/tools/renderers/TodoRenderer.tsx | 2 +- src/hooks/AutoScrollContext.ts | 20 + src/hooks/useDisclosureScrollLock.ts | 18 +- src/lib/overlayScrollbar.ts | 14 + 20 files changed, 1181 insertions(+), 139 deletions(-) create mode 100644 src/features/chat/DESIGN.md create mode 100644 src/features/chat/virtual/useAutoScroll.test.tsx create mode 100644 src/hooks/AutoScrollContext.ts diff --git a/src/components/CodeBlock.tsx b/src/components/CodeBlock.tsx index 5a46bb8e..951f261c 100644 --- a/src/components/CodeBlock.tsx +++ b/src/components/CodeBlock.tsx @@ -236,7 +236,7 @@ export const CodeBlock = memo(function CodeBlock({ className={`rounded-sm overflow-hidden bg-bg-200/25 contain-content ${className}`} style={containerStyle} > -
+
{content}
@@ -275,7 +275,7 @@ export const CodeBlock = memo(function CodeBlock({ )} {/* Scrollable content */} -
+
{content}
diff --git a/src/components/DiffViewer.tsx b/src/components/DiffViewer.tsx index 8bc964ef..cfbd173c 100644 --- a/src/components/DiffViewer.tsx +++ b/src/components/DiffViewer.tsx @@ -773,6 +773,7 @@ const WrappedSplitDiffView = memo(function WrappedSplitDiffView({ return (
+
) diff --git a/src/components/ui/ScrollArea.tsx b/src/components/ui/ScrollArea.tsx index 634e236c..9b3f05b8 100644 --- a/src/components/ui/ScrollArea.tsx +++ b/src/components/ui/ScrollArea.tsx @@ -17,6 +17,7 @@ export const ScrollArea = forwardRef( return (
{} @@ -110,6 +111,8 @@ interface ChatAreaProps { bottomPadding?: number onVisibleMessageIdsChange?: (ids: string[]) => void onAtBottomChange?: (atBottom: boolean) => void + /** 用户跟随状态变化时调用:true=正在贴底跟随,false=用户主动停止跟随 */ + onFollowingChange?: (following: boolean) => void } export type ChatAreaHandle = { @@ -329,7 +332,7 @@ export const ChatArea = memo( loadState = 'idle', loadError, connectionError, onOpenSettings, hasMoreHistory = false, onLoadMore, onUndo, onFork, canUndo, registerMessage, retryStatus = null, bottomPadding = 0, - onVisibleMessageIdsChange, onAtBottomChange, + onVisibleMessageIdsChange, onAtBottomChange, onFollowingChange, }, ref, ) => { @@ -415,6 +418,7 @@ export const ChatArea = memo( const onLoadMoreRef = useRef(onLoadMore); onLoadMoreRef.current = onLoadMore const onVisibleIdsRef = useRef(onVisibleMessageIdsChange); onVisibleIdsRef.current = onVisibleMessageIdsChange const onAtBottomRef = useRef(onAtBottomChange); onAtBottomRef.current = onAtBottomChange + const onFollowingRef = useRef(onFollowingChange); onFollowingRef.current = onFollowingChange const hasMoreRef = useRef(hasMoreHistory); hasMoreRef.current = hasMoreHistory const loadStateRef = useRef(loadState); loadStateRef.current = loadState const thresholdRef = useRef(atBottomThreshold); thresholdRef.current = atBottomThreshold @@ -430,18 +434,20 @@ export const ChatArea = memo( const autoSetScrollRef = auto.setScrollRef const autoSetContentRef = auto.setContentRef const autoHandleScroll = auto.handleScroll - const autoHandleWheel = auto.handleWheel - const autoHandleInteraction = auto.handleInteraction const autoForceScroll = auto.forceScrollToBottom const autoScrollBottom = auto.scrollToBottom const autoPause = auto.pause - const autoMarkAuto = auto.markAuto const userScrolledRef = auto.userScrolledRef + const autoSetPinToBottom = auto.setPinToBottom const spacerHeight = bottomSpacerHeight(bottomPadding) // 贴底判断必须读 ref:wheel→stop 后 state 还没 re-render, // 若仍用 state,同一帧的 ResizeObserver 会误判仍可贴底。 const shouldAnchorBottom = () => !userScrolledRef.current + // 给 message tree 里的 disclosure widget 用:用户主动操作(展开/折叠) + // 时通知这里停止贴底跟随。值 stable(pause 是 useCallback),不会引起消费方 re-render。 + const autoScrollCtxValue = useMemo(() => ({ pause: autoPause }), [autoPause]) + // ── 滚动状态(同步计算,不使用 rAF) ── const prevState = useRef({ overflow: false, bottom: true, jump: false }) const computeScrollState = useCallback(() => { @@ -502,7 +508,6 @@ export const ChatArea = memo( // 预写 total height,避免浏览器把新 offset clamp 到旧高度(oc 同款) scrollToFn: (offset, options, instance) => { if (contentRef.current) contentRef.current.style.height = `${instance.getTotalSize()}px` - autoMarkAuto(scrollRef.current) elementScroll(offset, options, instance) }, anchorTo: 'end', @@ -540,13 +545,13 @@ export const ChatArea = memo( }) }) } - // 核心修复:用户已上滚(userScrolledRef)时,临时关掉 anchorTo:'end', + // 用户已上滚(userScrolledRef)时,临时关掉 anchorTo:'end', // 阻止 virtual-core resizeItem 内部的 wasAtEnd 路径(applyScrollAdjustment 拉回)。 // wasAtEnd 用 getVirtualDistanceFromEnd()(基于内部 scrollOffset), // 但 React commit 阶段 ref 回调触发 measureElement 时 scroll 事件还没 fire, // scrollOffset 是陈旧的(仍指向底部),wasAtEnd 误判为 true → 拉回。 - // userScrolledRef 由 handleWheel 上滚设 true,只由 handleWheel 下滚回底设 false, - // handleScroll 不清它(避免流式增长推回时误清)。 + // userScrolledRef 现在只由输入事件设置(useAutoScroll 的 wheel/touch/...), + // handleScroll 在用户回到底部阈值内时清掉。 if (userScrolledRef.current) { const opts = (virtualizer as any).options const origAnchor = opts.anchorTo @@ -569,7 +574,6 @@ export const ChatArea = memo( if (!(virtualizer as any).isAtEnd?.(80)) return const el = scrollRef.current if (!el) return - autoMarkAuto(el) const max = Math.max(0, el.scrollHeight - el.clientHeight) if (max - el.scrollTop >= 2) el.scrollTop = max }) @@ -679,10 +683,14 @@ export const ChatArea = memo( // 必须滚到整页底(含 retry/error + 输入框 spacer),不能只 scrollToEnd 虚拟消息区 const el = scrollRef.current if (!el) return - autoMarkAuto(el) const max = Math.max(0, el.scrollHeight - el.clientHeight) if (max - el.scrollTop >= 2) el.scrollTop = max - }, [autoMarkAuto]) + }, []) + + // 把 pinToBottom 注入 useAutoScroll,让 drift 自愈路径能调到 + useEffect(() => { + autoSetPinToBottom(pinToBottom) + }, [autoSetPinToBottom, pinToBottom]) // ── 事件处理 ── const onScroll = useCallback(() => { @@ -700,10 +708,9 @@ export const ChatArea = memo( autoHandleScroll() }, [updatePrependAnchor, computeScrollState, autoHandleScroll, loadMore, userScrolledRef]) - const onWheel = useCallback((e: React.WheelEvent) => { + const onWheel = useCallback(() => { if (!prependLoading.current) clearPrepend() - autoHandleWheel(e.nativeEvent) - }, [autoHandleWheel, clearPrepend]) + }, [clearPrepend]) const onTouchStart = useCallback(() => { if (!prependLoading.current) clearPrepend() @@ -770,6 +777,12 @@ export const ChatArea = memo( return () => cancelAnimationFrame(frame) }, [auto.userScrolled, autoScrollBottom, pinToBottom]) + // 通知父组件跟随状态变化(toBottom 按钮的显隐依据) + // useLayoutEffect 避免新 session remount 时按钮闪烁 + useLayoutEffect(() => { + onFollowingRef.current?.(!auto.userScrolled) + }, [auto.userScrolled]) + // fill effect useEffect(() => { if (!sessionId || loadState !== 'loaded' || isLoadingMore || auto.userScrolled || !hasMoreHistory) return @@ -832,22 +845,16 @@ export const ChatArea = memo( useImperativeHandle(ref, () => ({ scrollToBottom: () => { autoForceScroll() - pinToBottom() }, scrollToBottomIfAtBottom: () => { - // userScrolled 守卫:用户上滚后 userScrolled=true,此函数由 onScrollRequest - //(每个 SSE chunk)调用。autoForceScroll() 的 force=true 会清掉 userScrolled, - // 导致 resizeItem 的 anchorTo toggle 失效 → wasAtEnd 恢复拉回。 - // 不在此处清 userScrolled——用户主动下滚回底时 handleWheel 会清。 - // 正常贴底跟随(userScrolled=false 时)不受影响。 + // 每个 SSE chunk 调用一次。userScrolled=true(用户主动操作过)时直接 return, + // 否则贴底。不再用 prevState.bottom 做二次门控—— + // 用户手势是唯一停止信号,drift 之类不应该让 chunk 停止 pin。 if (userScrolledRef.current) return - if (!prevState.current.bottom) return - autoForceScroll() pinToBottom() }, scrollToLastMessage: () => { if (timeline.length === 0) return - autoMarkAuto(scrollRef.current) virtualizer.scrollToIndex(timeline.length - 1, { align: 'end' }) }, scrollToMessageIndex: (index: number) => { @@ -867,10 +874,11 @@ export const ChatArea = memo( autoPause() virtualizer.scrollToIndex(timelineIndex, { align: 'center' }) }, - }), [autoForceScroll, autoPause, autoMarkAuto, pinToBottom, virtualizer, timeline, visibleMessages, messageIdToTimelineIndex]) + }), [autoForceScroll, autoPause, pinToBottom, virtualizer, timeline, visibleMessages, messageIdToTimelineIndex]) return ( -
+ +
{loadState === 'loading' && visibleMessages.length === 0 && (
@@ -891,7 +899,6 @@ export const ChatArea = memo( onWheel={onWheel} onTouchStart={onTouchStart} onScroll={onScroll} - onClick={autoHandleInteraction} > {visibleMessages.length > 0 && isLoadingMore && (
@@ -961,7 +968,8 @@ export const ChatArea = memo( -
+
+ ) }, ), diff --git a/src/features/chat/ChatPane.tsx b/src/features/chat/ChatPane.tsx index 8ca49902..2dc6a267 100644 --- a/src/features/chat/ChatPane.tsx +++ b/src/features/chat/ChatPane.tsx @@ -223,6 +223,8 @@ export const ChatPane = memo(function ChatPane({ setVisibleMessageIds(ids) }, []) const [isAtBottom, setIsAtBottom] = useState(true) + /** 用户是否在贴底跟随(true=正在跟随,false=用户主动停止) */ + const [isFollowing, setIsFollowing] = useState(true) const handleOutlineScrollToMessage = useCallback((messageId: string) => { chatAreaRef.current?.scrollToMessageId(messageId) @@ -841,6 +843,7 @@ export const ChatPane = memo(function ChatPane({ bottomPadding={inputBoxHeight} onVisibleMessageIdsChange={handleVisibleIdsChange} onAtBottomChange={setIsAtBottom} + onFollowingChange={setIsFollowing} /> )} @@ -914,7 +917,7 @@ export const ChatPane = memo(function ChatPane({ onClearRevert={clearRevert} registerInputBox={registerInputBox} isAtBottom={isAtBottom} - showScrollToBottom={!isAtBottom} + showScrollToBottom={!isFollowing} onScrollToBottom={() => chatAreaRef.current?.scrollToBottom()} collapsedPermission={ !inlineToolRequests && pendingPermissionRequests.length > 0 && permissionCollapsed diff --git a/src/features/chat/DESIGN.md b/src/features/chat/DESIGN.md new file mode 100644 index 00000000..f714e4ed --- /dev/null +++ b/src/features/chat/DESIGN.md @@ -0,0 +1,250 @@ +# Chat scroll-follow design + +This document explains the design of the chat scroll-following system. +If you're touching `useAutoScroll.ts`, `ChatArea.tsx`, or any input +handler that affects scroll behavior, read this first. + +## Core principle + +`userScrolled` is the single source of truth for "is the user actively +following the chat stream to the bottom". It is set ONLY by direct user +input events, NEVER by `scroll` events alone. + +This is a deliberate inversion of the previous design, which tried to +reverse-engineer user intent from `scroll` events using a `markAuto` +timestamp token (programmatic writes were marked, then `scroll` events +checked whether a recent mark existed). The old design had several +unfixable bugs because some `scroll` events have no JS-visible input +precursor (layout shifts, viewport resize clamps, find-in-page, history +restoration) — see `CONSTRAINTS` memory #60. + +## State machine + +```mermaid +stateDiagram-v2 + [*] --> following + following --> stopped: stopFollow (wheel-up / touch / scrollbar / key-up / selection-start / disclosure-expand) + stopped --> following: forceScrollToBottom (button) + stopped --> following: tryRecover (wheel-down / key-down / OS_DRAG_END)\n+ atBottom + stopped --> following: tryRecover opened window\n+ scroll event reaches atBottom within 500ms + following --> following: drift (content grows)\n→ scheduleRecoverPin +``` + +Two states, three transition types. No intermediate state. + +## Input → intent mapping + +| Input | Condition | Action | +|---|---|---| +| `wheel` (deltaY < 0) | target in chat root, not editable, no nested boundary | `stopFollow()` | +| `wheel` (deltaY > 0) | target in chat root, not editable, no nested boundary | mark recovery gesture; `handleScroll` completes it at bottom | +| `wheel` (any direction) | target inside marked nested scrollable, movement stays inside it | no-op for chat follow | +| `wheel` (deltaY < 0) | marked nested scrollable is already at top | `stopFollow()` | +| `wheel` (deltaY > 0) | marked nested scrollable is already at bottom | mark recovery gesture; outer scroll may complete it | +| `touchstart` | target in chat root, not editable | `stopFollow()` + reset `touchMaxDownRef` | +| `touchmove` | during a touch gesture | update `touchMaxDownRef` (max downward displacement) | +| `touchend` | `touchMaxDownRef > 10` (real downward drag) | `tryRecover()` | +| `touchend` | no downward drag (tap / up-fling) | no-op | +| `pointerdown` on scrollbar | target === scroll root, clientX in scrollbar region | `stopFollow()` | +| `pointerup` | (any) | no-op (mouse-up is not a "scroll down" gesture) | +| `keydown` PageUp/Home/ArrowUp | focus in chat root, not editable | `stopFollow()` | +| `keydown` PageDown/End/ArrowDown | focus in chat root, not editable | `tryRecover()` | +| `selectionchange` empty → non-empty | selection anchor in chat root | `stopFollow()` | +| `selectionchange` non-empty → empty | (any) | no-op | +| `OS_DRAG_START` (overlayScrollbar) | custom event from custom scrollbar thumb | `stopFollow()` | +| `OS_DRAG_END` (overlayScrollbar) | custom event on pointerup from thumb | `tryRecover()` | +| disclosure expand (`withScrollLock(_, true)`) | | `stopFollow()` | +| disclosure collapse (`withScrollLock(_, false)`) | | no-op | +| `scrollToMessageId` / `scrollToMessageIndex` | | calls `pause()` (= `stopFollow`) | + +## Recovery rules + +`userScrolled` is cleared by: + +1. `forceScrollToBottom()` — imperative "scroll to bottom" button +2. `tryRecover()` or `markRecoverGesture()` — invoked by an explicit "down" input: + - If `scrollTop` is within `bottomThreshold` → clear immediately + - Otherwise → open a 500ms recovery window; subsequent `scroll` events + that find `scrollTop` back at bottom will complete the recovery. + This handles iOS momentum and "drag-to-bottom-then-release-with-1px-error". +3. The recovery window closes on: success, timeout (500ms elapsed), or any + subsequent `stopFollow` / `setScrolled` call. + +`scroll` events themselves **do not** clear `userScrolled` outside of an +active recovery window — this is what makes "tiny wheel-up persists stop" +work correctly. + +## Why this works + +The fundamental insight is that user input events have well-defined +intent at the moment they fire: + +- `wheel` with `deltaY < 0` → user wants to go up +- `touchstart` → user is touching (likely about to scroll) +- `pointerdown` on scrollbar → user is dragging the scrollbar +- `keydown` PageUp → user wants to go up +- `selectionchange` empty → non-empty → user is selecting text + +Each input declares its intent. We don't need to infer it from a +downstream `scroll` event that conflates user and programmatic scrolls. + +## Why nested scrollables need special handling + +When the wheel target is inside a nested scrollable (code block, diff +viewer, long thinking block), the user is reading content *inside* that +block, not navigating the chat. Wheel-down inside a code block doesn't +mean "I want to follow the chat" — it means "scroll this code down". + +Detection: `findScrollableAncestor(target, root)` finds the nearest +ancestor marked with `data-scrollable`. `shouldMarkBoundaryGesture()` +then checks whether the wheel delta would move that nested element past +its top or bottom boundary. This mirrors OpenCode's boundary model: +scrolling inside a block is private to that block; only an overflow +attempt becomes an outer chat gesture. + +New nested vertical scroll components should mark their scroll viewport +with `data-scrollable`. Shared `ScrollArea`, `CodeBlock`, `DiffViewer`, +and Markdown code-preview viewports already do this. + +## Bottom threshold + +Single threshold: `bottomThreshold = 10` (px). Used for: + +- `tryRecover` deciding whether to clear `userScrolled` +- `handleScroll` deciding whether to schedule `recoverPin` + +The 60/150px thresholds in `ChatArea.tsx` are for `isAtBottom` (passed +to `useMobileCollapse` for the input-box pill behavior) and are +unrelated to follow state. + +## AutoScrollContext + +Disclosure widgets (Tool, Reasoning, Task, Todo, Subtask, System, +MessageError, ProcessCollapse) live deep in the message tree, far from +ChatArea which owns the `useAutoScroll` instance. They use +`useDisclosureScrollLock`, which internally `useContext(AutoScrollContext)` +to get a `pause` callback wired to `useAutoScroll.pause`. + +The context value is stable (memoized on `auto.pause`, which is itself +a stable `useCallback`), so consuming components don't re-render when +the value changes (it doesn't change). + +## ToBottom button visibility + +Driven by `userScrolled` (via `onFollowingChange` callback from +`ChatArea` to `ChatPane`), NOT by positional `isAtBottom`. This means +a tiny wheel-up that doesn't move scroll position will still show the +button (because `userScrolled = true`). + +This is correct: the button is the recovery mechanism for "user has +stopped following". Whether they're positionally near the bottom is +irrelevant to whether they want to recover. + +## Testing + +29 integration tests in `useAutoScroll.test.tsx` cover the state machine +end-to-end (wheel, touch, keyboard, OS_DRAG, recovery window, nested +scrollable, rapid events). Tests use a `Harness` component that calls +`setScrollRef` from `useLayoutEffect` (matching React's production commit +order: ref callbacks fire before `useEffect`). + +Manual verification still recommended for browser-only behaviors +(real momentum scrolling, capture phase edge cases, getComputedStyle +under actual CSS): + +1. Streaming stays pinned to bottom +2. Tiny wheel-up persists stop (button shows) +3. Wheel-down recovers when reaching bottom (immediate or via window) +4. Touch scroll: drag up stops, genuine drag down to bottom recovers (tap doesn't) +5. Scrollbar drag (custom overlayScrollbar): stops on dragstart, recovers on dragend if at bottom +6. Keyboard: PageUp stops, PageDown recovers at bottom +7. Text selection stops following; clearing selection doesn't recover +8. Disclosure expand stops; collapse doesn't change state +9. Wheel inside code block only affects chat when it overflows the block boundary +10. Click "scroll to bottom" button always recovers +11. iOS momentum: drag down, release with 1-2px error → still recovers within 500ms window + +## Key scenario sequence diagrams + +### Tiny wheel-up persists stop + +```mermaid +sequenceDiagram + participant U as User + participant W as wheel listener + participant H as handleScroll + participant S as userScrolled + + U->>W: wheel-up (deltaY=-3, only moves 3px) + W->>S: stopFollow() → userScrolled=true + Note over S: button shows + Note over W: scroll event fires (scrollTop still in atBottom zone) + W->>H: scroll event + H->>H: check recovery window: none active + H->>H: userScrolled=true → return early + Note over S: still true (persists stop) +``` + +### Disclosure expand during streaming + +```mermaid +sequenceDiagram + participant U as User + participant D as disclosure widget + participant C as AutoScrollContext + participant A as useAutoScroll + + U->>D: click expand toggle + D->>D: withScrollLock(() => setExpanded(true), expanding=true) + D->>C: pause() via context + C->>A: stopFollow() + A->>A: userScrolled=true + Note over A: streaming chunk arrives → scrollToBottomIfAtBottom returns early + Note over A: user can read the expanded content +``` + +### iOS touch drag-down + momentum recovery + +```mermaid +sequenceDiagram + participant U as User + participant T as touch listeners + participant H as handleScroll + participant S as userScrolled + + U->>T: touchstart (y=500) + T->>S: stopFollow() + reset touchMaxDownRef + U->>T: touchmove (y=100, finger moved up 400px = scroll down 400px) + T->>T: touchMaxDownRef = 400 + U->>T: touchend (release at scrollTop=497, 3px from bottom) + T->>T: touchMaxDownRef > 10 → tryRecover() + T->>T: atBottom? 500-497=3 < 10 → close, but momentum continues + Note over T: actually 500-497=3, in threshold → tryRecover clears immediately + Note over S: userScrolled=false + Note over U: alt: release at scrollTop=485 (15px from bottom) + T->>T: tryRecover → not atBottom → open 500ms window + U->>H: momentum fires scroll events + H->>H: scrollTop reaches 495 → within window → setScrolled(false) + Note over S: userScrolled=false (recovered via window) +``` + +### Wheel inside nested code block (only boundary overflow escapes) + +```mermaid +sequenceDiagram + participant U as User + participant W as wheel listener + participant F as findScrollableAncestor + participant S as userScrolled + + U->>W: wheel-down (deltaY=+50) inside code block + W->>F: findScrollableAncestor(target, root) + F-->>W: returns code-block div (data-scrollable) + W->>F: shouldMarkBoundaryGesture(delta, nested) + F-->>W: false (nested block still has room) + Note over S: chat follow state is unchanged + U->>W: wheel-down again at code-block bottom + W->>F: shouldMarkBoundaryGesture(delta, nested) + F-->>W: true (wheel would overflow nested bottom) + W->>S: mark recovery gesture; outer scroll can recover at bottom +``` diff --git a/src/features/chat/virtual/useAutoScroll.test.tsx b/src/features/chat/virtual/useAutoScroll.test.tsx new file mode 100644 index 00000000..2b4ee90b --- /dev/null +++ b/src/features/chat/virtual/useAutoScroll.test.tsx @@ -0,0 +1,524 @@ +import { describe, it, expect, afterEach, vi } from 'vitest' +import { render, act } from '@testing-library/react' +import { useLayoutEffect } from 'react' +import { useAutoScroll } from './useAutoScroll' + +/** + * 集成测试 —— 验证 input-event-driven 状态机的核心转换。 + * + * jsdom 限制:scrollTop/scrollHeight/clientHeight 默认都是 0 且只读, + * 需要用 Object.defineProperty 注入 getter。getComputedStyle 默认返回空字符串, + * 需要在测试里 spy。 + */ + +interface ElDims { + scrollHeight?: number + clientHeight?: number + scrollTop?: number +} + +function mountScrollEl(dims: ElDims = {}) { + const state = { + scrollHeight: dims.scrollHeight ?? 1000, + clientHeight: dims.clientHeight ?? 500, + scrollTop: dims.scrollTop ?? 500, // 默认在底部 + } + const el = document.createElement('div') + Object.defineProperty(el, 'scrollHeight', { configurable: true, get: () => state.scrollHeight }) + Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => state.clientHeight }) + Object.defineProperty(el, 'scrollTop', { + configurable: true, + get: () => state.scrollTop, + set: (v: number) => { + state.scrollTop = v + }, + }) + Object.defineProperty(el, 'style', { configurable: true, value: {} }) + document.body.innerHTML = '' + document.body.appendChild(el) + return { el, state } +} + +function setDims(state: { scrollHeight: number; clientHeight: number; scrollTop: number }, patch: ElDims) { + if (patch.scrollHeight !== undefined) state.scrollHeight = patch.scrollHeight + if (patch.clientHeight !== undefined) state.clientHeight = patch.clientHeight + if (patch.scrollTop !== undefined) state.scrollTop = patch.scrollTop +} + +function fireWheel(target: Element, deltaY: number) { + target.dispatchEvent(new WheelEvent('wheel', { deltaY, bubbles: true, cancelable: true })) +} + +function fireKey(target: Element, key: string) { + target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })) +} + +function makeTouch(y: number): Touch { + return { clientY: y } as unknown as Touch +} + +function fireTouchStart(target: Element, y = 500) { + const e = new TouchEvent('touchstart', { bubbles: true, cancelable: true }) + Object.defineProperty(e, 'touches', { value: [makeTouch(y)], configurable: true }) + target.dispatchEvent(e) +} + +function fireTouchMove(target: Element, y: number) { + const e = new TouchEvent('touchmove', { bubbles: true, cancelable: true }) + Object.defineProperty(e, 'touches', { value: [makeTouch(y)], configurable: true }) + target.dispatchEvent(e) +} + +function fireTouchEnd(target: Element) { + const e = new TouchEvent('touchend', { bubbles: true, cancelable: true }) + Object.defineProperty(e, 'touches', { value: [], configurable: true }) + target.dispatchEvent(e) +} + +/** + * TestComponent 模拟 ChatArea:在 useLayoutEffect 里调 setScrollRef, + * 确保 ref 在 useAutoScroll 的 useEffect 运行前就绑定好(生产时 ref callback + * 在 commit 阶段调用,先于 useEffect)。 + */ +let capturedAuto: ReturnType | null = null +function Harness({ targetEl }: { targetEl: HTMLElement | null }) { + const auto = useAutoScroll(10) + // eslint-disable-next-line react-hooks/globals -- test-only side effect to expose latest hook value + capturedAuto = auto + useLayoutEffect(() => { + if (targetEl) auto.setScrollRef(targetEl) + }, [targetEl]) + return null +} + +function setup(el: HTMLElement | null) { + const utils = render() + return { + /** + * 读最新 hook 返回值。注意:不要解构! + * `const { getResult } = setup()` 会把 getter 求值成一次性快照, + * 后续 setState re-render 后 capturedAuto 更新了也读不到。 + */ + getResult: () => capturedAuto!, + rerender: (nextEl: HTMLElement | null) => utils.rerender(), + unmount: utils.unmount, + } +} + +describe('useAutoScroll', () => { + afterEach(() => { + capturedAuto = null + }) + + it('initial state: not scrolled (following)', () => { + const { el } = mountScrollEl() + const { getResult } = setup(el) + expect(getResult().userScrolled).toBe(false) + expect(getResult().userScrolledRef.current).toBe(false) + }) + + describe('wheel events', () => { + it('wheel-up (deltaY<0) stops following', () => { + const { el } = mountScrollEl() + const { getResult } = setup(el) + expect(getResult().userScrolled).toBe(false) + + act(() => fireWheel(el, -100)) + expect(getResult().userScrolled).toBe(true) + }) + + it('wheel-down (deltaY>0) at bottom recovers when scroll handles the gesture', () => { + const { el, state } = mountScrollEl({ scrollHeight: 1000, clientHeight: 500, scrollTop: 495 }) + const { getResult } = setup(el) + + act(() => fireWheel(el, -100)) + expect(getResult().userScrolled).toBe(true) + + setDims(state, { scrollTop: 495 }) + act(() => fireWheel(el, 100)) + act(() => getResult().handleScroll()) + expect(getResult().userScrolled).toBe(false) + }) + + it('wheel-down NOT at bottom does NOT recover', () => { + const { el, state } = mountScrollEl({ scrollHeight: 1000, clientHeight: 500, scrollTop: 200 }) + const { getResult } = setup(el) + + act(() => fireWheel(el, -100)) + expect(getResult().userScrolled).toBe(true) + + setDims(state, { scrollTop: 200 }) + act(() => fireWheel(el, 100)) + expect(getResult().userScrolled).toBe(true) + }) + + it('wheel inside nested scrollable only stops when the nested block reaches a boundary', () => { + const outer = document.createElement('div') + const inner = document.createElement('div') + let innerScrollTop = 300 + Object.defineProperty(outer, 'scrollHeight', { configurable: true, get: () => 1000 }) + Object.defineProperty(outer, 'clientHeight', { configurable: true, get: () => 500 }) + Object.defineProperty(outer, 'scrollTop', { configurable: true, get: () => 500, set: () => {} }) + Object.defineProperty(outer, 'style', { configurable: true, value: {} }) + Object.defineProperty(inner, 'scrollHeight', { configurable: true, get: () => 1000 }) + Object.defineProperty(inner, 'clientHeight', { configurable: true, get: () => 200 }) + Object.defineProperty(inner, 'scrollTop', { configurable: true, get: () => innerScrollTop }) + inner.dataset.scrollable = '' + document.body.innerHTML = '' + document.body.appendChild(outer) + outer.appendChild(inner) + + const { getResult } = setup(outer) + + // 嵌套块内部仍有空间时,wheel 只滚动嵌套块,不影响 chat 跟随。 + act(() => fireWheel(inner, 100)) + expect(getResult().userScrolled).toBe(false) + + // 到顶部后继续向上,边界溢出意图传给 chat。 + innerScrollTop = 0 + act(() => fireWheel(inner, -100)) + expect(getResult().userScrolled).toBe(true) + + // 已经停止后,在嵌套块底部继续向下,边界意图打开恢复窗口; + // 外层 scroll 到底时完成恢复。 + innerScrollTop = 800 + act(() => fireWheel(inner, 100)) + act(() => getResult().handleScroll()) + expect(getResult().userScrolled).toBe(false) + }) + + it('wheel on editable element does not affect state', () => { + const { el } = mountScrollEl() + const input = document.createElement('input') + el.appendChild(input) + const { getResult } = setup(el) + + act(() => fireWheel(input, -100)) + expect(getResult().userScrolled).toBe(false) + }) + }) + + describe('keyboard events', () => { + it('PageUp stops following', () => { + const { el } = mountScrollEl() + const { getResult } = setup(el) + + act(() => fireKey(el, 'PageUp')) + expect(getResult().userScrolled).toBe(true) + }) + + it('ArrowDown at bottom recovers', () => { + const { el } = mountScrollEl({ scrollTop: 495 }) + const { getResult } = setup(el) + + act(() => fireKey(el, 'PageUp')) + expect(getResult().userScrolled).toBe(true) + + act(() => fireKey(el, 'ArrowDown')) + expect(getResult().userScrolled).toBe(false) + }) + + it('keyboard on editable element does not affect state', () => { + const { el } = mountScrollEl() + const textarea = document.createElement('textarea') + el.appendChild(textarea) + const { getResult } = setup(el) + + act(() => fireKey(textarea, 'PageUp')) + expect(getResult().userScrolled).toBe(false) + }) + }) + + describe('touch events', () => { + it('touchstart stops following', () => { + const { el } = mountScrollEl() + const { getResult } = setup(el) + + act(() => fireTouchStart(el)) + expect(getResult().userScrolled).toBe(true) + }) + + it('touchend (plain release, no downward drag) does NOT recover', () => { + const { el, state } = mountScrollEl({ scrollTop: 495 }) + const { getResult } = setup(el) + + act(() => fireTouchStart(el, 500)) + expect(getResult().userScrolled).toBe(true) + + // 没有 move(没向下滚),直接松手:不应解除 userScrolled + setDims(state, { scrollTop: 495 }) + act(() => fireTouchEnd(el)) + expect(getResult().userScrolled).toBe(true) + }) + + it('touchend recovers only after a genuine downward drag to bottom', () => { + const { el, state } = mountScrollEl({ scrollTop: 200 }) + const { getResult } = setup(el) + + act(() => fireTouchStart(el, 500)) + expect(getResult().userScrolled).toBe(true) + + // 手指上移 400px → 内容向下滚 400px,已到底部 + setDims(state, { scrollTop: 495 }) + act(() => fireTouchMove(el, 100)) + act(() => fireTouchEnd(el)) + expect(getResult().userScrolled).toBe(false) + }) + + it('touchend after an upward drag does NOT recover', () => { + const { el, state } = mountScrollEl({ scrollTop: 495 }) + const { getResult } = setup(el) + + act(() => fireTouchStart(el, 100)) + expect(getResult().userScrolled).toBe(true) + + // 手指下移(内容向上滚)→ 不是向下滚 + setDims(state, { scrollTop: 480 }) + act(() => fireTouchMove(el, 500)) + act(() => fireTouchEnd(el)) + expect(getResult().userScrolled).toBe(true) + }) + }) + + describe('imperative API', () => { + it('pause() stops following', () => { + const { el } = mountScrollEl() + const { getResult } = setup(el) + + act(() => getResult().pause()) + expect(getResult().userScrolled).toBe(true) + }) + + it('pause() is no-op when content fits in viewport', () => { + const { el } = mountScrollEl({ scrollHeight: 100, clientHeight: 500 }) + const { getResult } = setup(el) + + act(() => getResult().pause()) + expect(getResult().userScrolled).toBe(false) + }) + + it('forceScrollToBottom() recovers even if user had stopped', () => { + const { el, state } = mountScrollEl() + const { getResult } = setup(el) + + act(() => fireWheel(el, -100)) + expect(getResult().userScrolled).toBe(true) + + act(() => getResult().forceScrollToBottom()) + expect(getResult().userScrolled).toBe(false) + expect(state.scrollTop).toBe(500) // max = 1000 - 500 + }) + + it('scrollToBottom(non-force) does nothing when user stopped', () => { + const { el, state } = mountScrollEl({ scrollTop: 100 }) + const { getResult } = setup(el) + + act(() => fireWheel(el, -100)) + expect(getResult().userScrolled).toBe(true) + + const before = state.scrollTop + act(() => getResult().scrollToBottom()) + expect(getResult().userScrolled).toBe(true) + expect(state.scrollTop).toBe(before) + }) + }) + + describe('handleScroll', () => { + it('does NOT clear userScrolled when at bottom (key invariant)', () => { + const { el, state } = mountScrollEl({ scrollTop: 495 }) + const { getResult } = setup(el) + + act(() => fireWheel(el, -100)) + expect(getResult().userScrolled).toBe(true) + + setDims(state, { scrollTop: 495 }) + act(() => getResult().handleScroll()) + expect(getResult().userScrolled).toBe(true) + }) + + it('schedules recoverPin when following but drifted off bottom', async () => { + const { el, state } = mountScrollEl({ scrollTop: 100 }) + const { getResult } = setup(el) + + const pinFn = vi.fn() + act(() => getResult().setPinToBottom(pinFn)) + + setDims(state, { scrollTop: 100 }) + act(() => getResult().handleScroll()) + + await act(async () => { + await new Promise(r => requestAnimationFrame(r)) + }) + expect(pinFn).toHaveBeenCalled() + }) + + it('does NOT schedule recoverPin when user stopped', async () => { + const { el } = mountScrollEl({ scrollTop: 100 }) + const { getResult } = setup(el) + + const pinFn = vi.fn() + act(() => getResult().setPinToBottom(pinFn)) + + act(() => fireWheel(el, -100)) + act(() => getResult().handleScroll()) + + await act(async () => { + await new Promise(r => requestAnimationFrame(r)) + }) + expect(pinFn).not.toHaveBeenCalled() + }) + + it('does NOT schedule recoverPin when at bottom (no drift)', async () => { + const { el, state } = mountScrollEl({ scrollTop: 495 }) + const { getResult } = setup(el) + + const pinFn = vi.fn() + act(() => getResult().setPinToBottom(pinFn)) + + setDims(state, { scrollTop: 495 }) + act(() => getResult().handleScroll()) + + await act(async () => { + await new Promise(r => requestAnimationFrame(r)) + }) + expect(pinFn).not.toHaveBeenCalled() + }) + }) + + describe('selection', () => { + it('does not crash when selectionchange fires with empty selection', () => { + const { el } = mountScrollEl() + const { getResult } = setup(el) + + act(() => { + document.dispatchEvent(new Event('selectionchange')) + }) + expect(getResult().userScrolled).toBe(false) + }) + }) + + describe('recovery window', () => { + it('tryRecover NOT at bottom opens a recovery window; subsequent scroll to bottom recovers', () => { + const { el, state } = mountScrollEl({ scrollTop: 200 }) + const { getResult } = setup(el) + + // 用户停止 + act(() => fireWheel(el, -100)) + expect(getResult().userScrolled).toBe(true) + + // wheel-down 但还没到底:打开恢复窗口,不立刻恢复 + setDims(state, { scrollTop: 300 }) + act(() => fireWheel(el, 100)) + expect(getResult().userScrolled).toBe(true) + + // momentum 把 scrollTop 带到底部 → handleScroll 在窗口内恢复 + setDims(state, { scrollTop: 495 }) + act(() => getResult().handleScroll()) + expect(getResult().userScrolled).toBe(false) + }) + + it('recovery window closes when user stopFollow again', () => { + const { el, state } = mountScrollEl({ scrollTop: 200 }) + const { getResult } = setup(el) + + // 用户先停止 + act(() => fireWheel(el, -100)) + expect(getResult().userScrolled).toBe(true) + + // wheel-down 但还没到底:打开恢复窗口,不立刻恢复 + setDims(state, { scrollTop: 300 }) + act(() => fireWheel(el, 100)) + expect(getResult().userScrolled).toBe(true) + + // 用户又向上滚 → stopFollow 关掉窗口 + setDims(state, { scrollTop: 290 }) + act(() => fireWheel(el, -100)) + expect(getResult().userScrolled).toBe(true) + + // 即使后续到达底部,没有窗口也不会被 scroll 事件清掉 + setDims(state, { scrollTop: 495 }) + act(() => getResult().handleScroll()) + expect(getResult().userScrolled).toBe(true) + }) + }) + + describe('OS_DRAG events (overlayScrollbar)', () => { + it('OS_DRAG_START stops following, OS_DRAG_END at bottom recovers', () => { + const { el, state } = mountScrollEl({ scrollTop: 495 }) + const { getResult } = setup(el) + + act(() => el.dispatchEvent(new CustomEvent('os-scroll-dragstart', { bubbles: true }))) + expect(getResult().userScrolled).toBe(true) + + setDims(state, { scrollTop: 495 }) + act(() => el.dispatchEvent(new CustomEvent('os-scroll-dragend', { bubbles: true }))) + expect(getResult().userScrolled).toBe(false) + }) + + it('OS_DRAG_END not at bottom does NOT recover', () => { + const { el, state } = mountScrollEl({ scrollTop: 100 }) + const { getResult } = setup(el) + + act(() => el.dispatchEvent(new CustomEvent('os-scroll-dragstart', { bubbles: true }))) + expect(getResult().userScrolled).toBe(true) + + setDims(state, { scrollTop: 100 }) + act(() => el.dispatchEvent(new CustomEvent('os-scroll-dragend', { bubbles: true }))) + expect(getResult().userScrolled).toBe(true) + }) + }) + + describe('rapid / multiple events', () => { + it('100 rapid wheel-up events do not crash and end with userScrolled=true', () => { + const { el } = mountScrollEl() + const { getResult } = setup(el) + + act(() => { + for (let i = 0; i < 100; i++) fireWheel(el, -100) + }) + expect(getResult().userScrolled).toBe(true) + }) + + it('alternating wheel-up / wheel-down sequence ends with the last direction', () => { + const { el, state } = mountScrollEl({ scrollTop: 495 }) + const { getResult } = setup(el) + + // up, down, up, down + act(() => fireWheel(el, -100)) + expect(getResult().userScrolled).toBe(true) + + setDims(state, { scrollTop: 495 }) + act(() => fireWheel(el, 100)) + act(() => getResult().handleScroll()) + expect(getResult().userScrolled).toBe(false) + + act(() => fireWheel(el, -100)) + expect(getResult().userScrolled).toBe(true) + + setDims(state, { scrollTop: 495 }) + act(() => fireWheel(el, 100)) + act(() => getResult().handleScroll()) + expect(getResult().userScrolled).toBe(false) + }) + }) + + describe('nested scrollable + keyboard', () => { + it('PageUp inside a nested scrollable still stops (nested affects wheel only, not keyboard)', () => { + const outer = document.createElement('div') + const inner = document.createElement('div') + Object.defineProperty(outer, 'scrollHeight', { configurable: true, get: () => 1000 }) + Object.defineProperty(outer, 'clientHeight', { configurable: true, get: () => 500 }) + Object.defineProperty(outer, 'scrollTop', { configurable: true, get: () => 500, set: () => {} }) + Object.defineProperty(outer, 'style', { configurable: true, value: {} }) + document.body.innerHTML = '' + document.body.appendChild(outer) + outer.appendChild(inner) + + inner.dataset.scrollable = '' + + const { getResult } = setup(outer) + act(() => fireKey(inner, 'PageUp')) + expect(getResult().userScrolled).toBe(true) + }) + }) +}) diff --git a/src/features/chat/virtual/useAutoScroll.ts b/src/features/chat/virtual/useAutoScroll.ts index a4a2a4cb..1b7ca760 100644 --- a/src/features/chat/virtual/useAutoScroll.ts +++ b/src/features/chat/virtual/useAutoScroll.ts @@ -1,118 +1,314 @@ /** - * useAutoScroll — React 移植自 oc 的 createAutoScroll + * useAutoScroll — input-event-driven scroll-follow state machine * - * 核心机制: - * - userScrolled: 用户离开底部后置 true,阻止程序拉回底部 - * - markAuto/isAuto: 程序滚动时打标记(1500ms TTL, 2px 容差), - * 防止自己的 scrollToBottom 被误判为用户滚动 - * - handleScroll 可无手势门控调用:靠 isAuto 区分程序滚动 - * - 所有回调稳定(useCallback + useMemo),避免 ref 回调重挂载 + * 核心原则:userScrolled 只由**直接的用户输入**设置(wheel 向上、touch、滚动条 + * 拖拽、键盘向上、selection 起、disclosure 展开、scroll-to-message)。 + * 绝不由 scroll 事件单独设置。这意味着 layout clamp、virtualizer 调整、 + * viewport resize 等任何「无用户手势」的滚动都不会破坏跟随 —— 它们不匹配 + * 任何输入信号。 + * + * 恢复(清掉 userScrolled)在三种情况下发生: + * (a) forceScrollToBottom(命令式「回到底部」按钮) + * (b) 显式向下输入时 scrollTop 已在 bottomThreshold 内 —— 立即恢复 + * (c) 显式向下输入时 scrollTop 还没到底 —— 打开 500ms 恢复窗口, + * 窗口内的 scroll 事件若发现已回到底部,就完成恢复。 + * 解决 iOS momentum / 触屏「拖到底松手时差几 px」的边界。 + * + * 关键:scroll 事件本身**不会**清 userScrolled,除非在显式向下输入打开的 + * 恢复窗口内 —— 这样小幅 wheel-up 后紧随的 scroll 事件即使发现 atBottom=true + * 也不会把刚表达的停止意图清掉(没有窗口)。 + * + * handleScroll 唯一职责:用户在跟随时若发生 drift(scrollTop 离开底部), + * 排一个 rAF 调 pinToBottom 写回去。 + * + * 不再需要 markAuto/isAuto token —— 用户输入在事件源头捕获, + * 而不是从 scroll 事件里事后推断。 */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { OS_DRAG_START, OS_DRAG_END } from '../../../lib/overlayScrollbar' + +const SCROLL_UP_KEYS = new Set(['PageUp', 'Home', 'ArrowUp']) +const SCROLL_DOWN_KEYS = new Set(['PageDown', 'End', 'ArrowDown']) + +/** tryRecover 没立即成功时打开的恢复窗口时长 —— 给 momentum / 连续 scroll 一个机会完成恢复 */ +const RECOVERY_WINDOW_MS = 500 + +/** 这些元素上的 wheel / 键盘滚动不影响跟随状态(用户在编辑) */ +const EDITABLE_SELECTOR = 'input, textarea, select, [contenteditable="true"], [contenteditable=""]' + +/** + * 输入事件 handlers 工厂 + 统一 attach/detach。 + * 把监听器配置从 useEffect 主体里抽出来,让 useAutoScroll 主流程更清晰。 + * handlers 闭包捕获 ctx 里的 ref 和回调 —— 这些值由 useAutoScroll 提供。 + */ +interface InputHandlerContext { + el: HTMLElement + stopFollow: () => void + tryRecover: () => void + markRecoverGesture: () => void + touchStartYRef: React.MutableRefObject + touchMaxDownRef: React.MutableRefObject + lastSelEmptyRef: React.MutableRefObject +} + +function createInputHandlers(ctx: InputHandlerContext) { + const { el, stopFollow, tryRecover, markRecoverGesture } = ctx + + const onWheel = (e: WheelEvent) => { + const t = e.target instanceof Element ? e.target : null + if (!t || !el.contains(t)) return + if (t.closest(EDITABLE_SELECTOR)) return + const delta = normalizeWheelDelta(e, el) + const nested = findScrollableAncestor(t, el) + if (nested && !shouldMarkBoundaryGesture(nested, delta)) return + if (delta < 0) { + stopFollow() + } else if (delta > 0) { + // 下滚的实际恢复由 handleScroll 完成;这里仅打开用户恢复窗口。 + markRecoverGesture() + } + } + + const onTouchStart = (e: TouchEvent) => { + const t = e.target instanceof Element ? e.target : null + if (!t || !el.contains(t)) return + if (t.closest(EDITABLE_SELECTOR)) return + stopFollow() + ctx.touchStartYRef.current = e.touches[0]?.clientY ?? 0 + ctx.touchMaxDownRef.current = 0 + } + + const onTouchMove = (e: TouchEvent) => { + const y = e.touches[0]?.clientY ?? 0 + // 手指上移 = 内容向下滚,d>0 表示本次手势向下滚动过 + const d = ctx.touchStartYRef.current - y + if (d > ctx.touchMaxDownRef.current) ctx.touchMaxDownRef.current = d + } + + const onTouchEnd = () => { + // 只有「本次触摸是向下滚动手势且已回到底部」才恢复跟随。 + // 普通点击、上滚、或微小抖动都不算向下滚动 → 不解除 userScrolled, + // 否则会出现「刚解除贴底、松手就又被拉回底部」的问题。 + if (ctx.touchMaxDownRef.current > 10) tryRecover() + } + + const onPointerDown = (e: PointerEvent) => { + // touch 走 touchstart 处理;这里只处理鼠标 / 笔 + if (e.pointerType === 'touch') return + const t = e.target instanceof Element ? e.target : null + // 滚动条是 scroll root 自己渲染的,target === el + if (!t || t !== el) return + const rect = el.getBoundingClientRect() + const sbWidth = rect.width - el.clientWidth + if (sbWidth <= 0) return + if (e.clientX >= rect.right - sbWidth) stopFollow() + } + + const onPointerUp = () => { + // 鼠标松开本身不是「向下滚动」手势,不恢复跟随。 + // 鼠标向下滚动靠 wheel(deltaY>0) 恢复;滚动条拖拽靠 OS_DRAG_END 恢复。 + } + + const onKeyDown = (e: KeyboardEvent) => { + const t = e.target instanceof Element ? e.target : null + if (!t || !el.contains(t)) return + if (t.closest(EDITABLE_SELECTOR)) return + if (SCROLL_UP_KEYS.has(e.key)) { + stopFollow() + } else if (SCROLL_DOWN_KEYS.has(e.key)) { + tryRecover() + } + } + + const onSelectionChange = () => { + const sel = window.getSelection() + const isEmpty = !sel || sel.toString().length === 0 + if (isEmpty === ctx.lastSelEmptyRef.current) return + ctx.lastSelEmptyRef.current = isEmpty + // 只有「开始选中文字」才停止跟随;清空 selection 不改变状态 + if (isEmpty) return + // 只对 chat root 内的 selection 起反应 + const node = sel.anchorNode + const nodeEl = node + ? (node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement) + : null + if (!nodeEl || !el.contains(nodeEl)) return + stopFollow() + } + + // 自绘滚动条(overlayScrollbar)拖拽:原生 scrollbar 被全局隐藏, + // 它的 thumb 在父元素上且 stopPropagation,pointerdown 检测不到。 + // 这里改监听 overlayScrollbar 广播的 dragstart/dragend。 + const onOsDragStart = () => stopFollow() + const onOsDragEnd = () => tryRecover() + + return { + onWheel, + onTouchStart, + onTouchMove, + onTouchEnd, + onPointerDown, + onPointerUp, + onKeyDown, + onSelectionChange, + onOsDragStart, + onOsDragEnd, + } +} + +/** 用 AbortController 统一管理所有 listener 的注册/卸载 */ +function attachInputListeners(el: HTMLElement, h: ReturnType) { + const ac = new AbortController() + const capture = { capture: true, passive: true, signal: ac.signal } + const bubble = { passive: true, signal: ac.signal } + const keydownOpts = { signal: ac.signal } + const osOpts = { signal: ac.signal } + + el.addEventListener('wheel', h.onWheel, capture) + el.addEventListener('touchstart', h.onTouchStart, capture) + el.addEventListener('touchmove', h.onTouchMove, capture) + el.addEventListener('touchend', h.onTouchEnd, bubble) + el.addEventListener('pointerdown', h.onPointerDown, capture) + el.addEventListener('pointerup', h.onPointerUp, bubble) + el.addEventListener('keydown', h.onKeyDown, keydownOpts) + el.addEventListener(OS_DRAG_START, h.onOsDragStart, osOpts) + el.addEventListener(OS_DRAG_END, h.onOsDragEnd, osOpts) + document.addEventListener('selectionchange', h.onSelectionChange, osOpts) + + return () => ac.abort() +} + +/** + * 从 target 向上找显式标记的嵌套滚动祖先(不含 root 自己)。 + * 嵌套块使用 data-scrollable 表达边界,避免每次 wheel 都调用 getComputedStyle。 + */ +function findScrollableAncestor(target: Element, root: HTMLElement): HTMLElement | null { + const nested = target.closest('[data-scrollable]') + if (!nested || nested === root || !root.contains(nested)) return null + return nested +} + +function normalizeWheelDelta(event: WheelEvent, root: HTMLElement) { + if (event.deltaMode === 1) return event.deltaY * 40 + if (event.deltaMode === 2) return event.deltaY * root.clientHeight + return event.deltaY +} -const AUTO_TTL = 1500 -const AUTO_TOLERANCE = 2 +function shouldMarkBoundaryGesture(nested: HTMLElement, delta: number) { + const max = nested.scrollHeight - nested.clientHeight + if (max <= 1) return true + if (!delta) return false + if (delta < 0) return nested.scrollTop + delta <= 0 + return delta > max - nested.scrollTop +} export function useAutoScroll(bottomThreshold = 10) { const scrollElRef = useRef(undefined) const contentElRef = useRef(undefined) const userScrolledRef = useRef(false) const [userScrolled, setUserScrolled] = useState(false) - - const autoMark = useRef<{ top: number; time: number } | undefined>(undefined) - const autoTimer = useRef | undefined>(undefined) + const lastSelEmptyRef = useRef(true) + const recoverPinFrame = useRef(undefined) + /** 触摸手势中向下滚动的最大位移,用于 touchend 判断是否真的「向下滚到底」 */ + const touchStartYRef = useRef(0) + const touchMaxDownRef = useRef(0) + /** 由 ChatArea 注入的「写到底部」函数,drift 时调用以恢复贴底 */ + const pinToBottomRef = useRef<(() => void) | null>(null) + /** + * 恢复窗口的截止时间戳(Date.now() + RECOVERY_WINDOW_MS)。 + * 0 = 没有窗口。显式向下输入在不在线底部时打开窗口, + * 之后 scroll 事件若发现已回到底部,就完成恢复。 + * 解决 iOS momentum / 触屏「拖到底松手时差几 px」的边界。 + */ + const recoverUntilRef = useRef(0) const setScrolled = useCallback((v: boolean) => { + if (userScrolledRef.current === v) return userScrolledRef.current = v setUserScrolled(v) + recoverUntilRef.current = 0 }, []) - const markAuto = useCallback((el?: HTMLElement | null) => { - const target = el ?? scrollElRef.current - if (!target) return - autoMark.current = { top: target.scrollHeight - target.clientHeight, time: Date.now() } - if (autoTimer.current) clearTimeout(autoTimer.current) - autoTimer.current = setTimeout(() => { autoMark.current = undefined }, AUTO_TTL) - }, []) - - const isAuto = useCallback((el: HTMLElement) => { - const a = autoMark.current - if (!a) return false - if (Date.now() - a.time > AUTO_TTL) { autoMark.current = undefined; return false } - return Math.abs(el.scrollTop - a.top) < AUTO_TOLERANCE - }, []) - - const scrollToBottom = useCallback((force: boolean) => { + /** 用户表达「停止跟随」:直接置 userScrolled=true,并关掉任何恢复窗口 */ + const stopFollow = useCallback(() => { const el = scrollElRef.current - if (!el) return - if (force && userScrolledRef.current) setScrolled(false) - if (!force && userScrolledRef.current) return - const max = Math.max(0, el.scrollHeight - el.clientHeight) - if (max - el.scrollTop < 2) { - markAuto(el) - return - } - markAuto(el) - el.scrollTop = max - }, [markAuto, setScrolled]) - - const stop = useCallback(() => { - const el = scrollElRef.current - if (!el) return - if (el.scrollHeight - el.clientHeight <= 1) { + if (el && el.scrollHeight - el.clientHeight <= 1) { + // 内容不足一屏,谈不上跟随 if (userScrolledRef.current) setScrolled(false) return } - if (userScrolledRef.current) return + recoverUntilRef.current = 0 setScrolled(true) }, [setScrolled]) - const handleScroll = useCallback(() => { + /** + * 用户表达「向下」意愿(wheel-down / 向下键 / touchend / pointerup): + * 若 scrollTop 已回到 bottomThreshold 内,立即恢复跟随; + * 否则打开 500ms 恢复窗口 —— 让 momentum / 后续 scroll 事件完成恢复。 + */ + const tryRecover = useCallback(() => { + if (!userScrolledRef.current) return const el = scrollElRef.current if (!el) return const max = el.scrollHeight - el.clientHeight - if (max <= 1) { - // isAuto 守卫:程序滚动(applyScrollAdjustment 经 scrollToFn→markAuto) - // 不清 userScrolled,只有真实用户滚动到无溢出时才清。 - if (userScrolledRef.current && !isAuto(el)) setScrolled(false) - return - } if (max - el.scrollTop < bottomThreshold) { - // isAuto 守卫:流式增长推回底部(程序滚动,isAuto=true)不清 userScrolled。 - // 用户真实滚动回底(isAuto=false)才清,恢复贴底跟随。 - if (userScrolledRef.current && !isAuto(el)) setScrolled(false) - return - } - if (!userScrolledRef.current && isAuto(el)) { - scrollToBottom(false) + setScrolled(false) return } - stop() - }, [bottomThreshold, isAuto, scrollToBottom, setScrolled, stop]) + recoverUntilRef.current = Date.now() + RECOVERY_WINDOW_MS + }, [bottomThreshold, setScrolled]) + + const markRecoverGesture = useCallback(() => { + if (!userScrolledRef.current) return + recoverUntilRef.current = Date.now() + RECOVERY_WINDOW_MS + }, []) - const handleWheel = useCallback((e: WheelEvent) => { + // ── 漂移自愈:scroll 事件发现离底但 userScrolled=false 时排个 rAF 写回底 ── + const scheduleRecoverPin = useCallback(() => { + if (recoverPinFrame.current !== undefined) return + recoverPinFrame.current = requestAnimationFrame(() => { + recoverPinFrame.current = undefined + if (userScrolledRef.current) return + pinToBottomRef.current?.() + }) + }, []) + + const setPinToBottom = useCallback((fn: (() => void) | null) => { + pinToBottomRef.current = fn + }, []) + + // ── scroll 事件:负责 drift pin 和 recovery window,永远不直接清 userScrolled(除恢复窗口内) ── + const handleScroll = useCallback(() => { const el = scrollElRef.current if (!el) return - if (e.deltaY >= 0) { - // 下滚回底时恢复贴底跟随:用户主动下滚到阈值内才清 userScrolled。 - // 流式增长推回不会走这里(不是 wheel 事件)。 - if (userScrolledRef.current) { - const max = el.scrollHeight - el.clientHeight - if (max - el.scrollTop < bottomThreshold) setScrolled(false) + const max = el.scrollHeight - el.clientHeight + const atBottom = max <= 1 || max - el.scrollTop < bottomThreshold + + // 恢复窗口:用户表达过「向下」意愿后,给 momentum 一段时间把 scrollTop 带到底部 + if (userScrolledRef.current && recoverUntilRef.current > 0) { + if (Date.now() > recoverUntilRef.current) { + recoverUntilRef.current = 0 + } else if (atBottom) { + setScrolled(false) + return } - return } - // 上滚立刻离底 - const nested = (e.target instanceof Element ? e.target : undefined)?.closest('[data-scrollable]') - if (nested && nested !== el) return - // 直接写 ref,不等 React re-render——同帧的 RO/measure 必须立刻看到离底 - if (!userScrolledRef.current) setScrolled(true) - }, [bottomThreshold, setScrolled]) - const handleInteraction = useCallback(() => { - const sel = window.getSelection() - if (sel && sel.toString().length > 0) stop() - }, [stop]) + if (userScrolledRef.current) return + if (!atBottom) scheduleRecoverPin() + }, [bottomThreshold, scheduleRecoverPin, setScrolled]) + + // ── 命令式动作 ── + const scrollToBottom = useCallback((force: boolean) => { + const el = scrollElRef.current + if (!el) return + if (force && userScrolledRef.current) setScrolled(false) + if (!force && userScrolledRef.current) return + const max = Math.max(0, el.scrollHeight - el.clientHeight) + if (max - el.scrollTop >= 2) el.scrollTop = max + }, [setScrolled]) + + const pause = stopFollow const setScrollRef = useCallback((el: HTMLElement | null) => { scrollElRef.current = el ?? undefined @@ -123,40 +319,50 @@ export function useAutoScroll(bottomThreshold = 10) { contentElRef.current = el ?? undefined }, []) - // 不使用 contentRef ResizeObserver: - // measureElement 内置 RO → resizeItem → applyScrollAdjustment 已经处理了贴底。 - // contentRef RO 会在 item 首次测量时触发(container height 变化), - // 把 scrollTop 拉回底部,覆盖 applyScrollAdjustment 的正确行为。 + // ── 输入事件监听器:mount 一次,cleanup 通过 AbortController 统一 ── + useEffect(() => { + const el = scrollElRef.current + if (!el) return + const handlers = createInputHandlers({ + el, + stopFollow, + tryRecover, + markRecoverGesture, + touchStartYRef, + touchMaxDownRef, + lastSelEmptyRef, + }) + return attachInputListeners(el, handlers) + }, [markRecoverGesture, stopFollow, tryRecover]) - useEffect(() => () => { if (autoTimer.current) clearTimeout(autoTimer.current) }, []) + useEffect(() => () => { + if (recoverPinFrame.current !== undefined) cancelAnimationFrame(recoverPinFrame.current) + }, []) - const reset = useCallback(() => { - setScrolled(false) - }, [setScrolled]) + const reset = useCallback(() => setScrolled(false), [setScrolled]) const resume = useCallback(() => { setScrolled(false) scrollToBottom(true) }, [scrollToBottom, setScrolled]) + const scrollToBottomCb = useCallback(() => scrollToBottom(false), [scrollToBottom]) const forceScrollToBottom = useCallback(() => scrollToBottom(true), [scrollToBottom]) return useMemo(() => ({ setScrollRef, setContentRef, + setPinToBottom, handleScroll, - handleWheel, - handleInteraction, - pause: stop, + pause, reset, resume, - markAuto, scrollToBottom: scrollToBottomCb, forceScrollToBottom, userScrolledRef, userScrolled, }), [ - setScrollRef, setContentRef, handleScroll, handleWheel, handleInteraction, - stop, reset, resume, markAuto, scrollToBottomCb, forceScrollToBottom, userScrolled, + setScrollRef, setContentRef, setPinToBottom, handleScroll, pause, + reset, resume, scrollToBottomCb, forceScrollToBottom, userScrolled, ]) } diff --git a/src/features/message/MessageRenderer.tsx b/src/features/message/MessageRenderer.tsx index ab7a3d3b..5871987f 100644 --- a/src/features/message/MessageRenderer.tsx +++ b/src/features/message/MessageRenderer.tsx @@ -503,7 +503,7 @@ const CollapsibleUserText = memo(function CollapsibleUserText({ {showCollapse && ( {showScrollToBottom && }
diff --git a/src/features/chat/input/QueuedMessagesBar.tsx b/src/features/chat/input/QueuedMessagesBar.tsx new file mode 100644 index 00000000..23b2e2c5 --- /dev/null +++ b/src/features/chat/input/QueuedMessagesBar.tsx @@ -0,0 +1,158 @@ +import { memo, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { SendIcon, ClockIcon, CloseIcon } from '../../../components/Icons' +import { usePresence } from '../../../hooks' +import type { QueuedFollowupDraft } from '../../../store/followupQueueStore' + +// ============================================ +// QueuedMessagesBar — 输入框上方的排队消息预览条 +// 宽度与输入框一致,每条消息独立一行,文本溢出截断 + hover 展示全文 +// ============================================ + +interface QueuedMessagesBarProps { + items: QueuedFollowupDraft[] + failedId?: string + sendingId?: string + onRemove: (id: string) => void + onCancelFailed: (id: string) => void + onSendNow: (id: string) => void +} + +/** 多行文本压缩为单行,供 title tooltip 使用 */ +function tooltipText(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} + +const QueuedMessageRow = memo(function QueuedMessageRow({ + item, + isFailed, + isSending, + onRemove, + onCancelFailed, + onSendNow, +}: { + item: QueuedFollowupDraft + isFailed: boolean + isSending: boolean + onRemove: (id: string) => void + onCancelFailed: (id: string) => void + onSendNow: (id: string) => void +}) { + const { t } = useTranslation('chat') + const { shouldRender, ref } = usePresence(true, { + from: { opacity: 0, transform: 'translateY(-4px)' }, + to: { opacity: 1, transform: 'translateY(0)' }, + duration: 0.2, + }) + + const handleRemove = useCallback(() => onRemove(item.id), [item.id, onRemove]) + const handleCancelFailed = useCallback(() => onCancelFailed(item.id), [item.id, onCancelFailed]) + const handleSendNow = useCallback(() => onSendNow(item.id), [item.id, onSendNow]) + const fullText = tooltipText(item.text) + + if (!shouldRender) return null + + return ( +
+ {/* 状态图标 */} + + {isSending ? ( + + ) : ( + + )} + + + {/* 消息文本 — 溢出截断,hover 展示全文 */} + + {fullText} + + + {/* 附件标记 */} + {item.attachments.length > 0 && ( + + 📎{item.attachments.length} + + )} + + {/* agent 标记 */} + {item.agent && ( + + {item.agent} + + )} + + {/* 失败标记 */} + {isFailed && ( + + {t('queuedMessages.failed')} + + )} + + {/* 立即发送按钮(仅排队中显示) */} + {!isFailed && !isSending && ( + + )} + + {/* 删除/放弃按钮 */} + +
+ ) +}) + +export const QueuedMessagesBar = memo(function QueuedMessagesBar({ + items, + failedId, + sendingId, + onRemove, + onCancelFailed, + onSendNow, +}: QueuedMessagesBarProps) { + if (items.length === 0) return null + + return ( +
+ {items.map(item => ( + + ))} +
+ ) +}) diff --git a/src/hooks/useChatSession.ts b/src/hooks/useChatSession.ts index 076f37b4..99606e7f 100644 --- a/src/hooks/useChatSession.ts +++ b/src/hooks/useChatSession.ts @@ -284,86 +284,6 @@ export function useChatSession({ } }, [approvePendingOnFullAuto, fullAutoMode, pendingPermissionRequests, replyPermissionOnceAutomatically]) - const buildLocalQueuedMessage = useCallback( - (input: { - sessionId: string - messageId: string - text: string - attachments: Attachment[] - agent?: string - model: { providerID: string; modelID: string; variant?: string } - createdAt: number - }): UIMessage => { - const parts: UIMessage['parts'] = [ - { - id: `${input.messageId}:text`, - type: 'text', - text: input.text, - synthetic: false, - sessionID: input.sessionId, - messageID: input.messageId, - }, - ] - - for (const attachment of input.attachments) { - if (attachment.type === 'agent') { - parts.push({ - id: attachment.id || `${input.messageId}:agent:${parts.length}`, - type: 'agent', - name: attachment.agentName || attachment.displayName, - source: attachment.textRange - ? { - value: attachment.textRange.value, - start: attachment.textRange.start, - end: attachment.textRange.end, - } - : undefined, - sessionID: input.sessionId, - messageID: input.messageId, - }) - continue - } - - if (attachment.type !== 'file' && attachment.type !== 'folder') continue - - parts.push({ - id: attachment.id || `${input.messageId}:file:${parts.length}`, - type: 'file', - mime: attachment.mime || (attachment.type === 'folder' ? 'application/x-directory' : 'text/plain'), - filename: attachment.displayName, - url: attachment.url || '', - source: attachment.textRange - ? { - type: 'file', - path: attachment.relativePath || attachment.displayName, - text: { - value: attachment.textRange.value, - start: attachment.textRange.start, - end: attachment.textRange.end, - }, - } - : undefined, - sessionID: input.sessionId, - messageID: input.messageId, - }) - } - - return { - info: { - id: input.messageId, - sessionID: input.sessionId, - role: 'user', - time: { created: input.createdAt }, - agent: input.agent || '', - model: input.model, - }, - parts, - isStreaming: false, - } - }, - [], - ) - // ============================================ // SSE 事件回调(permission / question / scroll / idle / error / reconnect) // 每个 pane 都注册自己的 consumer,由 App 顶层统一建立 SSE 连接 @@ -742,7 +662,7 @@ export function useChatSession({ !!routeSessionId && (queuedFollowups.length > 0 || (queueFollowupMessages && isSessionBusy)) if (shouldQueueFollowup) { - const queued = followupQueueStore.enqueue({ + followupQueueStore.enqueue({ sessionId: routeSessionId, directory: effectiveDirectory || '', text: content, @@ -755,17 +675,6 @@ export function useChatSession({ variant: options?.variant, agent: options?.agent, }) - messageStore.upsertLocalMessage( - buildLocalQueuedMessage({ - sessionId: queued.sessionId, - messageId: queued.id, - text: queued.text, - attachments: queued.attachments, - agent: queued.agent, - model: queued.model, - createdAt: queued.createdAt, - }), - ) return true } @@ -790,7 +699,6 @@ export function useChatSession({ queueFollowupMessages, isSessionBusy, effectiveDirectory, - buildLocalQueuedMessage, sendMessageNow, ], ) @@ -801,9 +709,6 @@ export function useChatSession({ if (!draft) return false if (!followupQueueStore.startSending(draft.sessionId, draft.id)) return false - // 发送前先移除占位消息,让 sendMessageNow 走和正常发送完全一样的路径 - messageStore.removeMessage(draft.sessionId, draft.id) - const ok = await sendMessageNow({ sessionId: draft.sessionId, content: draft.text, @@ -826,11 +731,6 @@ export function useChatSession({ } else { // 标记失败,阻塞后续队列项 followupQueueStore.markFailed(draft.sessionId, draft.id) - // 移除剩余排队消息的本地占位,恢复队头到输入框 - const remaining = followupQueueStore.getItems(draft.sessionId) - for (const item of remaining) { - messageStore.removeMessage(draft.sessionId, item.id) - } setRestoredContent({ sessionId: draft.sessionId, content: { @@ -849,6 +749,27 @@ export function useChatSession({ [sendMessageNow], ) + // 立即发送排队消息(跳过队列检查,直接发送) + const handleSendQueuedNow = useCallback( + async (draftId: string) => { + if (!routeSessionId) return false + const draft = followupQueueStore.getItem(routeSessionId, draftId) + if (!draft) return false + + followupQueueStore.remove(routeSessionId, draftId) + + return sendMessageNow({ + sessionId: routeSessionId, + content: draft.text, + attachments: draft.attachments, + model: { providerID: draft.model.providerID, modelID: draft.model.modelID }, + options: { agent: draft.agent, variant: draft.variant }, + directory: draft.directory, + }) + }, + [routeSessionId, sendMessageNow], + ) + useEffect(() => { if (!routeSessionId) return @@ -938,6 +859,8 @@ export function useChatSession({ // Abort handler const handleAbort = useCallback(async () => { if (!routeSessionId) return + // 放弃当前回复时清空队列,排队消息不应继续发送 + followupQueueStore.clearSession(routeSessionId) try { const directory = sessionDirectory || currentDirectory await abortSession(routeSessionId, directory) @@ -1163,6 +1086,7 @@ export function useChatSession({ pendingQuestionRequests, queuedFollowups, queuedFollowupSendingId, + queuedFollowupFailedId, handlePermissionReply, handleQuestionReply, handleQuestionReject, @@ -1179,6 +1103,7 @@ export function useChatSession({ // Handlers handleSend, + handleSendQueuedNow, handleAbort, handleCommand, handleUndoWithAnimation, diff --git a/src/locales/en/chat.json b/src/locales/en/chat.json index 821cbc8a..f02091ae 100644 --- a/src/locales/en/chat.json +++ b/src/locales/en/chat.json @@ -192,6 +192,14 @@ "cacheRW": "Cache (r/w)", "rawMessages": "Raw Messages" }, + "queuedMessages": { + "count": "{{count}} queued", + "sending": "Sending…", + "failed": "Failed", + "remove": "Remove from queue", + "cancelFailed": "Dismiss", + "sendNow": "Send now" + }, "hints": { "pressEscAgain": "Press <1>Esc again to stop", "autoApproveAll": "Auto-approve: all sessions", diff --git a/src/locales/zh-CN/chat.json b/src/locales/zh-CN/chat.json index f21529ca..8c868c95 100644 --- a/src/locales/zh-CN/chat.json +++ b/src/locales/zh-CN/chat.json @@ -192,6 +192,14 @@ "cacheRW": "缓存 (读/写)", "rawMessages": "原始消息" }, + "queuedMessages": { + "count": "{{count}} 条排队中", + "sending": "发送中…", + "failed": "发送失败", + "remove": "从队列移除", + "cancelFailed": "放弃", + "sendNow": "立即发送" + }, "hints": { "pressEscAgain": "再按一次 <1>Esc 停止", "autoApproveAll": "自动放行:全部会话", From c56183f75d4158f04a038393a5f030d1614ae980 Mon Sep 17 00:00:00 2001 From: Louis LUO Date: Wed, 22 Jul 2026 10:48:23 +0800 Subject: [PATCH 04/13] feat(chat): selection only affects follow state during active streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selectionchange is now a no-op when the session is not streaming. This means selecting text to copy/reference while idle doesn't: - set userScrolled=true - show the toBottom button - break follow for the next message During streaming, selection still stops follow as before — the user is choosing to read instead of watch the stream. Implementation: - useAutoScroll: add isStreamingRef + setStreaming() method - onSelectionChange: early return if !isStreamingRef.current - ChatArea: useEffect syncs isStreaming prop → auto.setStreaming() - 2 new integration tests (selection during streaming stops, during idle doesn't) --- src/features/chat/ChatArea.tsx | 4 +++ src/features/chat/DESIGN.md | 7 +++- .../chat/virtual/useAutoScroll.test.tsx | 36 +++++++++++++++++++ src/features/chat/virtual/useAutoScroll.ts | 14 +++++++- 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/features/chat/ChatArea.tsx b/src/features/chat/ChatArea.tsx index 3b3aba52..08dc4a31 100644 --- a/src/features/chat/ChatArea.tsx +++ b/src/features/chat/ChatArea.tsx @@ -437,6 +437,7 @@ export const ChatArea = memo( const autoForceScroll = auto.forceScrollToBottom const autoScrollBottom = auto.scrollToBottom const autoPause = auto.pause + const autoSetStreaming = auto.setStreaming const userScrolledRef = auto.userScrolledRef const autoSetPinToBottom = auto.setPinToBottom const spacerHeight = bottomSpacerHeight(bottomPadding) @@ -448,6 +449,9 @@ export const ChatArea = memo( // 时通知这里停止贴底跟随。值 stable(pause 是 useCallback),不会引起消费方 re-render。 const autoScrollCtxValue = useMemo(() => ({ pause: autoPause }), [autoPause]) + // 同步 isStreaming 到 useAutoScroll —— selection 只在流式时才影响跟随 + useEffect(() => { autoSetStreaming(isStreaming) }, [autoSetStreaming, isStreaming]) + // ── 滚动状态(同步计算,不使用 rAF) ── const prevState = useRef({ overflow: false, bottom: true, jump: false }) const computeScrollState = useCallback(() => { diff --git a/src/features/chat/DESIGN.md b/src/features/chat/DESIGN.md index f714e4ed..65210b97 100644 --- a/src/features/chat/DESIGN.md +++ b/src/features/chat/DESIGN.md @@ -49,7 +49,7 @@ Two states, three transition types. No intermediate state. | `pointerup` | (any) | no-op (mouse-up is not a "scroll down" gesture) | | `keydown` PageUp/Home/ArrowUp | focus in chat root, not editable | `stopFollow()` | | `keydown` PageDown/End/ArrowDown | focus in chat root, not editable | `tryRecover()` | -| `selectionchange` empty → non-empty | selection anchor in chat root | `stopFollow()` | +| `selectionchange` empty → non-empty | selection anchor in chat root, **only during active streaming** | `stopFollow()` | | `selectionchange` non-empty → empty | (any) | no-op | | `OS_DRAG_START` (overlayScrollbar) | custom event from custom scrollbar thumb | `stopFollow()` | | `OS_DRAG_END` (overlayScrollbar) | custom event on pointerup from thumb | `tryRecover()` | @@ -88,6 +88,11 @@ intent at the moment they fire: Each input declares its intent. We don't need to infer it from a downstream `scroll` event that conflates user and programmatic scrolls. +**Selection during idle is a no-op.** `selectionchange` only calls +`stopFollow` when `isStreaming` is true. When the chat is idle, +selecting text to copy/reference doesn't break follow state or show +the toBottom button — there's nothing to unfollow. + ## Why nested scrollables need special handling When the wheel target is inside a nested scrollable (code block, diff diff --git a/src/features/chat/virtual/useAutoScroll.test.tsx b/src/features/chat/virtual/useAutoScroll.test.tsx index 2b4ee90b..40237557 100644 --- a/src/features/chat/virtual/useAutoScroll.test.tsx +++ b/src/features/chat/virtual/useAutoScroll.test.tsx @@ -395,6 +395,42 @@ describe('useAutoScroll', () => { }) expect(getResult().userScrolled).toBe(false) }) + + it('selection during streaming stops following', () => { + const { el } = mountScrollEl() + const { getResult } = setup(el) + + act(() => getResult().setStreaming(true)) + + // 模拟开始选中文字 + const sel = { + toString: () => 'selected', + anchorNode: el, + } as unknown as Selection + vi.spyOn(window, 'getSelection').mockReturnValue(sel) + + act(() => { + document.dispatchEvent(new Event('selectionchange')) + }) + expect(getResult().userScrolled).toBe(true) + }) + + it('selection during idle (non-streaming) does NOT stop following', () => { + const { el } = mountScrollEl() + const { getResult } = setup(el) + + // 不 setStreaming —— 默认 false + const sel = { + toString: () => 'selected', + anchorNode: el, + } as unknown as Selection + vi.spyOn(window, 'getSelection').mockReturnValue(sel) + + act(() => { + document.dispatchEvent(new Event('selectionchange')) + }) + expect(getResult().userScrolled).toBe(false) + }) }) describe('recovery window', () => { diff --git a/src/features/chat/virtual/useAutoScroll.ts b/src/features/chat/virtual/useAutoScroll.ts index 1b7ca760..4aae2aee 100644 --- a/src/features/chat/virtual/useAutoScroll.ts +++ b/src/features/chat/virtual/useAutoScroll.ts @@ -49,6 +49,7 @@ interface InputHandlerContext { touchStartYRef: React.MutableRefObject touchMaxDownRef: React.MutableRefObject lastSelEmptyRef: React.MutableRefObject + isStreamingRef: React.MutableRefObject } function createInputHandlers(ctx: InputHandlerContext) { @@ -121,6 +122,9 @@ function createInputHandlers(ctx: InputHandlerContext) { } const onSelectionChange = () => { + // 非流式时 selection 不影响跟随状态 —— 用户只是在复制/引用文字, + // 不应该导致 toBottom 按钮出现或下一条消息不贴底 + if (!ctx.isStreamingRef.current) return const sel = window.getSelection() const isEmpty = !sel || sel.toString().length === 0 if (isEmpty === ctx.lastSelEmptyRef.current) return @@ -205,6 +209,8 @@ function shouldMarkBoundaryGesture(nested: HTMLElement, delta: number) { export function useAutoScroll(bottomThreshold = 10) { const scrollElRef = useRef(undefined) const contentElRef = useRef(undefined) + /** 是否正在流式输出 —— selection 只在流式时才影响跟随状态 */ + const isStreamingRef = useRef(false) const userScrolledRef = useRef(false) const [userScrolled, setUserScrolled] = useState(false) const lastSelEmptyRef = useRef(true) @@ -319,6 +325,10 @@ export function useAutoScroll(bottomThreshold = 10) { contentElRef.current = el ?? undefined }, []) + const setStreaming = useCallback((v: boolean) => { + isStreamingRef.current = v + }, []) + // ── 输入事件监听器:mount 一次,cleanup 通过 AbortController 统一 ── useEffect(() => { const el = scrollElRef.current @@ -331,6 +341,7 @@ export function useAutoScroll(bottomThreshold = 10) { touchStartYRef, touchMaxDownRef, lastSelEmptyRef, + isStreamingRef, }) return attachInputListeners(el, handlers) }, [markRecoverGesture, stopFollow, tryRecover]) @@ -353,6 +364,7 @@ export function useAutoScroll(bottomThreshold = 10) { setScrollRef, setContentRef, setPinToBottom, + setStreaming, handleScroll, pause, reset, @@ -362,7 +374,7 @@ export function useAutoScroll(bottomThreshold = 10) { userScrolledRef, userScrolled, }), [ - setScrollRef, setContentRef, setPinToBottom, handleScroll, pause, + setScrollRef, setContentRef, setPinToBottom, setStreaming, handleScroll, pause, reset, resume, scrollToBottomCb, forceScrollToBottom, userScrolled, ]) } From 7cfa63dfb892701025e30c6655918816410f5f16 Mon Sep 17 00:00:00 2001 From: Louis LUO Date: Wed, 22 Jul 2026 12:13:55 +0800 Subject: [PATCH 05/13] fix(reasoning): keep capsule scroll when scrolled up during streaming The capsule reasoning view forced scroll-to-bottom on every streamed chunk, yanking the position even when the user had scrolled up to read earlier output. Match the 60px threshold already used by BashRenderer and TaskRenderer: only re-pin when within 60px of the bottom, otherwise leave the user's position untouched. --- .../message/parts/ReasoningPartView.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/features/message/parts/ReasoningPartView.tsx b/src/features/message/parts/ReasoningPartView.tsx index 0d486651..f0c6e38c 100644 --- a/src/features/message/parts/ReasoningPartView.tsx +++ b/src/features/message/parts/ReasoningPartView.tsx @@ -29,6 +29,7 @@ export const ReasoningPartView = memo(function ReasoningPartView({ part, isStrea const shouldRenderBody = useDelayedRender(expanded) const { rootRef, headerRef, withScrollLock } = useDisclosureScrollLock() const scrollAreaRef = useRef(null) + const isAtBottomRef = useRef(true) const summaryContainerRef = useRef(null) const summaryMeasureRef = useRef(null) const [summaryOverflow, setSummaryOverflow] = useState(false) @@ -76,10 +77,17 @@ export const ReasoningPartView = memo(function ReasoningPartView({ part, isStrea } }, [hasContent, isPartStreaming, setExpanded]) + const handleCapsuleScroll = useCallback(() => { + const el = scrollAreaRef.current + if (!el) return + isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 60 + }, []) + useEffect(() => { if (reasoningDisplayMode !== 'capsule') return - if (isPartStreaming && expanded && scrollAreaRef.current) { - scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight + const el = scrollAreaRef.current + if (isPartStreaming && expanded && isAtBottomRef.current && el) { + el.scrollTop = el.scrollHeight } }, [displayText, isPartStreaming, expanded, reasoningDisplayMode]) @@ -277,7 +285,12 @@ export const ReasoningPartView = memo(function ReasoningPartView({ part, isStrea >
{shouldRenderBody && ( - +
{displayText}
From 51a0ec647ea61aeeeea2960aac76721f5a2e626f Mon Sep 17 00:00:00 2001 From: Louis LUO Date: Wed, 22 Jul 2026 18:18:33 +0800 Subject: [PATCH 06/13] feat(text-selection-popup): Quote + Copy actions on chat text selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface a floating popup with Quote and Copy buttons when the user selects text inside a chat pane. Quote turns the selection into a Markdown blockquote and inserts it into the pane's input box at the cursor; Copy writes the raw selection to the clipboard. Architecture: - Mounted once in App, renders into document.body via createPortal - selectionchange tracks a pending ref; popup only commits on mouseup/touchend/Shift+arrow keyup — never flickers mid-drag - Positions near the pointer release point (mouse/touch) or the selection rect (keyboard) - Auto-dismisses on outside pointerdown, scroll, resize, Esc - data-pane-id added to ChatPane root so message selections resolve to the correct pane's textarea across split panes Filters out selections inside form controls, contenteditable, and [data-no-selection-popup] opt-out zones. Quote spacing follows Slack/Notion conventions (always at least one blank line around the block). 607 tests pass (34 in this feature). 0 lint warnings. --- src/App.tsx | 2 + src/components/Icons.tsx | 2 + src/features/chat/ChatPane.tsx | 1 + .../TextSelectionPopup.test.tsx | 226 +++++++++++++++ .../TextSelectionPopup.tsx | 272 ++++++++++++++++++ src/features/text-selection-popup/index.ts | 1 + .../text-selection-popup/popupUtils.test.ts | 252 ++++++++++++++++ .../text-selection-popup/popupUtils.ts | 147 ++++++++++ src/locales/en/chat.json | 7 + src/locales/zh-CN/chat.json | 7 + 10 files changed, 917 insertions(+) create mode 100644 src/features/text-selection-popup/TextSelectionPopup.test.tsx create mode 100644 src/features/text-selection-popup/TextSelectionPopup.tsx create mode 100644 src/features/text-selection-popup/index.ts create mode 100644 src/features/text-selection-popup/popupUtils.test.ts create mode 100644 src/features/text-selection-popup/popupUtils.ts diff --git a/src/App.tsx b/src/App.tsx index 7ce65ebd..224edad7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { invoke } from '@tauri-apps/api/core' import { Sidebar } from './features/chat' import { ChatPane } from './features/chat/ChatPane' import { SplitContainer } from './features/chat/SplitContainer' +import { TextSelectionPopup } from './features/text-selection-popup' import type { CommandItem } from './components/CommandPalette' import { ToastContainer } from './components/ToastContainer' import { RightPanel } from './components/RightPanel' @@ -985,6 +986,7 @@ function App() { )} +
diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index 2c77061d..c9c9e134 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -14,6 +14,7 @@ import { Check, Send, Plus, + Quote, GraduationCap, Settings, Sun, @@ -123,6 +124,7 @@ export const KeyboardIcon = wrap(Keyboard) export const CheckIcon = wrap(Check) export const SendIcon = wrap(Send) export const PlusIcon = wrap(Plus) +export const QuoteIcon = wrap(Quote) export const TeachIcon = wrap(GraduationCap) export const SettingsIcon = wrap(Settings) export const SunIcon = wrap(Sun) diff --git a/src/features/chat/ChatPane.tsx b/src/features/chat/ChatPane.tsx index 8ca49902..5959d89f 100644 --- a/src/features/chat/ChatPane.tsx +++ b/src/features/chat/ChatPane.tsx @@ -980,6 +980,7 @@ export const ChatPane = memo(function ChatPane({
({ + copyTextToClipboard: vi.fn(async () => undefined), +})) + +vi.mock('../../utils/errorHandling', () => ({ + clipboardErrorHandler: vi.fn(), +})) + +import { copyTextToClipboard } from '../../utils/clipboard' + +const COPY_MOCK = copyTextToClipboard as unknown as ReturnType + +let container: HTMLDivElement + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + COPY_MOCK.mockClear() +}) + +afterEach(() => { + document.body.removeChild(container) +}) + +function setupSelectionPane(selectionText: string) { + container.innerHTML = ` +
+
${selectionText}
+ +
+ ` + const anchor = container.querySelector('#anchor-text')!.firstChild! + const range = document.createRange() + range.setStart(anchor, 0) + range.setEnd(anchor, anchor.textContent!.length) + + // jsdom doesn't compute layout, so getBoundingClientRect returns 0s. The + // popup relies on a real rect to position itself, so override it for the + // synthetic range. In a real browser this stays untouched. + range.getBoundingClientRect = () => + ({ + top: 100, + left: 200, + right: 300, + bottom: 120, + width: 100, + height: 20, + x: 200, + y: 100, + toJSON: () => ({}), + }) as DOMRect + + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + return { + textarea: container.querySelector('[data-testid="textarea"]')!, + selection, + range, + } +} + +function releasePointer(clientX = 250, clientY = 110) { + fireEvent.mouseUp(document.body, { clientX, clientY }) +} + +async function flush() { + await act(async () => { + await new Promise(r => setTimeout(r, 50)) + }) +} + +describe('TextSelectionPopup', () => { + it('does NOT appear during a selection drag — only after mouseup commits', async () => { + setupSelectionPane('hello there') + + render() + await flush() + + // selectionchange ran mid-test setup; no mouseup yet → popup must be hidden. + expect(document.body.querySelector('[data-text-selection-popup]')).toBeNull() + + await act(async () => { + releasePointer(280, 140) + }) + + expect(document.body.querySelector('[data-text-selection-popup]')).not.toBeNull() + }) + + it('renders Quote and Copy buttons after mouseup over a valid selection', async () => { + setupSelectionPane('hello there') + render() + await flush() + await act(async () => { + releasePointer() + }) + + const popup = document.body.querySelector('[data-text-selection-popup]') + expect(popup).not.toBeNull() + const quoteButton = popup?.querySelector('button[aria-label="Quote"]') + const copyButton = popup?.querySelector('button[aria-label="Copy"]') + expect(quoteButton).not.toBeNull() + expect(copyButton).not.toBeNull() + }) + + it('does not appear for selections outside any data-pane-id container', async () => { + container.innerHTML = ` +
plain outside pane + ${'outside'} +
+ ` + const anchor = container.querySelector('#anchor-text')!.firstChild! + const range = document.createRange() + range.setStart(anchor, 0) + range.setEnd(anchor, anchor.textContent!.length) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + + render() + await flush() + await act(async () => { + releasePointer() + }) + + expect(document.body.querySelector('[data-text-selection-popup]')).toBeNull() + }) + + it('Quote writes a markdown block-quote into the pane textarea and focuses it', async () => { + const { textarea } = setupSelectionPane('how does this work?') + + render() + await flush() + await act(async () => { + releasePointer() + }) + + const quoteButton = document.body.querySelector('button[aria-label="Quote"]') as HTMLButtonElement + expect(quoteButton).not.toBeNull() + + act(() => { + fireEvent.click(quoteButton) + }) + + expect(textarea.value.startsWith('> how does this work?')).toBe(true) + expect(COPY_MOCK).not.toHaveBeenCalled() + }) + + it('Copy writes the selection to the clipboard without touching the textarea', async () => { + const { textarea } = setupSelectionPane('plain copy text') + render() + await flush() + await act(async () => { + releasePointer() + }) + + const copyButton = document.body.querySelector('button[aria-label="Copy"]') as HTMLButtonElement + expect(copyButton).not.toBeNull() + + await act(async () => { + fireEvent.click(copyButton) + }) + + expect(COPY_MOCK).toHaveBeenCalledWith('plain copy text') + expect(textarea.value).toBe('') + }) + + it('hides on Escape after a mouseup-committed selection', async () => { + setupSelectionPane('escape me') + render() + await flush() + await act(async () => { + releasePointer() + }) + expect(document.body.querySelector('[data-text-selection-popup]')).not.toBeNull() + + await act(async () => { + fireEvent.keyDown(document, { key: 'Escape' }) + }) + + expect(document.body.querySelector('[data-text-selection-popup]')).toBeNull() + }) + + it('hides on scroll after a mouseup-committed selection', async () => { + setupSelectionPane('scroll away') + render() + await flush() + await act(async () => { + releasePointer() + }) + expect(document.body.querySelector('[data-text-selection-popup]')).not.toBeNull() + + await act(async () => { + window.dispatchEvent(new Event('scroll')) + }) + expect(document.body.querySelector('[data-text-selection-popup]')).toBeNull() + }) + + it('places the popup near the pointer release point, not the selection start', async () => { + setupSelectionPane('anchor for position math') + render() + await flush() + await act(async () => { + // Release at coordinates far from the synthetic selection rect + // (rect is at top=100/left=200 — pick something the rect-aware path + // would never choose). + releasePointer(900, 600) + }) + + const popup = document.body.querySelector('[data-text-selection-popup]') + expect(popup).not.toBeNull() + // Read the inline style values applied by computePopupAtPointer. + expect(popup!.style.position).toBe('fixed') + expect(popup!.style.top).toMatch(/^\d+(\.\d+)?px$/) + expect(popup!.style.left).toMatch(/^\d+(\.\d+)?px$/) + const leftPx = Number.parseFloat(popup!.style.left) + // Should not coincide with the synthetic rect's left (200). + expect(leftPx).not.toBe(200) + // 900 - popupWidth estimate (200) places the cursor's left edge of the popup. + expect(leftPx).toBeGreaterThan(500) + }) +}) diff --git a/src/features/text-selection-popup/TextSelectionPopup.tsx b/src/features/text-selection-popup/TextSelectionPopup.tsx new file mode 100644 index 00000000..9f10f3f7 --- /dev/null +++ b/src/features/text-selection-popup/TextSelectionPopup.tsx @@ -0,0 +1,272 @@ +/** + * TextSelectionPopup — global floating popup that surfaces "Quote" and "Copy" + * actions when the user highlights text inside a chat pane. + * + * - selectionchange tracks a *pending* selection into a ref. The popup + * never appears during a drag — it only commits on mouseup / touchend / + * keyboard Shift+arrow release so the toolbar never flickers mid-drag. + * - Positions near the pointer release point (mouse / touch), or near the + * selection's bounding rect for keyboard selections. + * - Renders into document.body via createPortal so its fixed position is + * unaffected by ancestor transform / overflow. + * - Auto-hides on: outside pointerdown, scroll, resize, Esc. + */ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { CheckIcon, CopyIcon, QuoteIcon } from '../../components/Icons' +import { copyTextToClipboard } from '../../utils/clipboard' +import { clipboardErrorHandler } from '../../utils/errorHandling' +import { + buildQuotePatch, + computePopupPosition, + findTargetTextarea, + shouldShowPopupForSelection, +} from './popupUtils' + +const POPUP_WIDTH_ESTIMATE = 180 +const POPUP_HEIGHT_ESTIMATE = 30 +const COPIED_FEEDBACK_MS = 1500 + +type PendingSelection = { + text: string + rect: DOMRect + textarea: HTMLTextAreaElement +} + +type PlacedPopup = PendingSelection & { + /** Pointer release point in viewport coordinates; null for keyboard selection. */ + pointer: { x: number; y: number } | null +} + +export function TextSelectionPopup() { + const { t } = useTranslation('chat') + const [placed, setPlaced] = useState(null) + const [copied, setCopied] = useState(false) + const [popupWidth, setPopupWidth] = useState(POPUP_WIDTH_ESTIMATE) + const [popupHeight, setPopupHeight] = useState(POPUP_HEIGHT_ESTIMATE) + const popupRef = useRef(null) + const copiedTimerRef = useRef | null>(null) + /** + * Latest valid selection, kept current via selectionchange. The popup + * never renders from this — only from mouseup/touchend/keyup commits. + */ + const pendingRef = useRef(null) + + // ── selectionchange: maintain pending ref only, never show popup ── + useEffect(() => { + if (typeof document === 'undefined') return + const handler = () => { + const selection = window.getSelection() + if (!selection || !shouldShowPopupForSelection(selection)) { + pendingRef.current = null + return + } + const textarea = findTargetTextarea(selection) + const range = selection.getRangeAt(0) + const rect = range.getBoundingClientRect() + // Zero-by-zero rect = browser hasn't laid out the selection (display:none + // content, or the jsdom test env). The toolbar needs real geometry. + if (!textarea || (rect.width === 0 && rect.height === 0)) { + pendingRef.current = null + return + } + pendingRef.current = { text: selection.toString(), rect, textarea } + } + document.addEventListener('selectionchange', handler) + return () => document.removeEventListener('selectionchange', handler) + }, []) + + // ── pointerdown outside popup: dismiss + clear pending ── + useEffect(() => { + if (typeof document === 'undefined') return + const handler = (e: PointerEvent) => { + const popup = popupRef.current + if (popup && e.target instanceof Node && popup.contains(e.target)) return + setPlaced(null) + pendingRef.current = null + } + document.addEventListener('pointerdown', handler) + return () => document.removeEventListener('pointerdown', handler) + }, []) + + // ── mouseup / touchend: commit pending + position at pointer ── + useEffect(() => { + if (typeof document === 'undefined') return + const commit = (pointer: { x: number; y: number } | null) => { + const pending = pendingRef.current + if (!pending) return + setPlaced({ ...pending, pointer }) + } + const onMouseUp = (e: MouseEvent) => commit({ x: e.clientX, y: e.clientY }) + const onTouchEnd = (e: TouchEvent) => { + const t = e.changedTouches[0] + if (t) commit({ x: t.clientX, y: t.clientY }) + } + document.addEventListener('mouseup', onMouseUp) + document.addEventListener('touchend', onTouchEnd) + return () => { + document.removeEventListener('mouseup', onMouseUp) + document.removeEventListener('touchend', onTouchEnd) + } + }, []) + + // ── keyboard Shift+arrow: commit pending, no pointer coords ── + useEffect(() => { + if (typeof document === 'undefined') return + const SELECTION_KEYS = new Set([ + 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', + 'Home', 'End', 'PageUp', 'PageDown', + ]) + const handler = (e: KeyboardEvent) => { + if (!e.shiftKey || !SELECTION_KEYS.has(e.key)) return + const pending = pendingRef.current + if (!pending) return + setPlaced({ ...pending, pointer: null }) + } + document.addEventListener('keyup', handler) + return () => document.removeEventListener('keyup', handler) + }, []) + + // ── dismiss on scroll / resize / Escape (only while popup is open) ── + useEffect(() => { + if (!placed) return + const hide = () => setPlaced(null) + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setPlaced(null) + window.getSelection()?.removeAllRanges() + } + } + window.addEventListener('scroll', hide, true) + window.addEventListener('resize', hide) + document.addEventListener('keydown', onKey) + return () => { + window.removeEventListener('scroll', hide, true) + window.removeEventListener('resize', hide) + document.removeEventListener('keydown', onKey) + } + }, [placed]) + + // ── measure actual popup size for accurate positioning ── + useLayoutEffect(() => { + if (!placed) return + const popup = popupRef.current + if (!popup) return + const ro = new ResizeObserver(entries => { + for (const entry of entries) { + const { width, height } = entry.contentRect + if (width > 0) setPopupWidth(width) + if (height > 0) setPopupHeight(height) + } + }) + ro.observe(popup) + return () => ro.disconnect() + }, [placed]) + + // ── cleanup copied feedback timer on unmount ── + useEffect(() => { + return () => { + if (copiedTimerRef.current !== null) clearTimeout(copiedTimerRef.current) + } + }, []) + + const handleCopy = useCallback(async () => { + if (!placed) return + try { + await copyTextToClipboard(placed.text) + setCopied(true) + if (copiedTimerRef.current !== null) clearTimeout(copiedTimerRef.current) + copiedTimerRef.current = setTimeout(() => { + setCopied(false) + copiedTimerRef.current = null + }, COPIED_FEEDBACK_MS) + } catch (err) { + clipboardErrorHandler('copy selection', err) + } + }, [placed]) + + const handleQuote = useCallback(() => { + if (!placed) return + const textarea = placed.textarea + const existing = textarea.value + const cursor = typeof textarea.selectionStart === 'number' ? textarea.selectionStart : existing.length + const { newText, newCursor } = buildQuotePatch(existing, cursor, placed.text) + + // Drive the textarea via its native value setter so React sees a real + // input event and syncs the controlled state on the next render. + const valueSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(textarea), 'value')?.set + if (valueSetter) valueSetter.call(textarea, newText) + textarea.dispatchEvent(new Event('input', { bubbles: true })) + + requestAnimationFrame(() => { + textarea.focus() + textarea.setSelectionRange(newCursor, newCursor) + }) + + window.getSelection()?.removeAllRanges() + setPlaced(null) + }, [placed]) + + // Position: use pointer coords when available (mouse/touch), otherwise + // the selection's bounding rect (keyboard). + const position = useMemo(() => { + if (!placed) return null + const anchor = placed.pointer + ? { top: placed.pointer.y, bottom: placed.pointer.y, left: placed.pointer.x } + : { top: placed.rect.top, bottom: placed.rect.bottom, left: placed.rect.left } + const vw = typeof window !== 'undefined' ? window.innerWidth : 1024 + return computePopupPosition(anchor, popupHeight, popupWidth, vw) + }, [placed, popupHeight, popupWidth]) + + if (!placed || !position) return null + + return createPortal( +
e.preventDefault()} + > +
+ + +
, + document.body, + ) +} diff --git a/src/features/text-selection-popup/index.ts b/src/features/text-selection-popup/index.ts new file mode 100644 index 00000000..a3b13cec --- /dev/null +++ b/src/features/text-selection-popup/index.ts @@ -0,0 +1 @@ +export { TextSelectionPopup } from './TextSelectionPopup' diff --git a/src/features/text-selection-popup/popupUtils.test.ts b/src/features/text-selection-popup/popupUtils.test.ts new file mode 100644 index 00000000..c83deeab --- /dev/null +++ b/src/features/text-selection-popup/popupUtils.test.ts @@ -0,0 +1,252 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + buildQuotePatch, + computePopupPosition, + findTargetTextarea, + formatQuote, + shouldShowPopupForSelection, +} from './popupUtils' + +let container: HTMLDivElement + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) +}) + +afterEach(() => { + document.body.removeChild(container) +}) + +/** + * Build a minimal fake `Selection` for `findTargetTextarea` / `shouldShow*`. + * jsdom doesn't implement window.getSelection in a useful way, so we unit-test + * the helpers with stub objects that quack like a Selection. + */ +function makeFakeSelection(opts: { + anchorElement: Element | null + text?: string + isCollapsed?: boolean + rangeCount?: number +}): Partial { + const rangeCount = opts.rangeCount ?? 1 + return { + anchorNode: opts.anchorElement, + isCollapsed: opts.isCollapsed ?? false, + rangeCount, + toString: () => opts.text ?? '', + getRangeAt: () => ({}) as Range, + } +} + +describe('formatQuote', () => { + it('prefixes a single line with "> "', () => { + expect(formatQuote('hello world')).toBe('> hello world') + }) + + it('prefixes each line of a multi-line selection', () => { + expect(formatQuote('line one\nline two\nline three')).toBe('> line one\n> line two\n> line three') + }) + + it('emits a lone ">" for blank lines so Markdown stays a valid blockquote', () => { + expect(formatQuote('first\n\nthird')).toBe('> first\n>\n> third') + }) + + it('returns an empty string for empty input', () => { + expect(formatQuote('')).toBe('') + }) +}) + +describe('buildQuotePatch', () => { + it('quotes into an empty input and places the cursor at the end of the quote', () => { + const result = buildQuotePatch('', 0, 'first line\nsecond line') + expect(result.newText).toBe('> first line\n> second line\n\n') + expect(result.newCursor).toBe('> first line\n> second line'.length) + }) + + it('quotes at the end of a non-empty input that already ends with a newline', () => { + const existing = 'Please look at this:\n' + const result = buildQuotePatch(existing, existing.length, 'quoted block') + // Single trailing newline → add one more so the quote sits on a blank line. + expect(result.newText).toBe('Please look at this:\n\n> quoted block') + expect(result.newCursor).toBe(result.newText.length) + }) + + it('does not double-add a separator when the input already ends with a blank line', () => { + const existing = 'Already has a blank line:\n\n' + const result = buildQuotePatch(existing, existing.length, 'quoted block') + expect(result.newText).toBe('Already has a blank line:\n\n> quoted block') + }) + + it('quotes in the middle of a non-empty input while preserving surrounding text', () => { + const existing = 'prefix suffix' + const result = buildQuotePatch(existing, 'prefix '.length, 'line one\nline two') + expect(result.newText).toBe('prefix \n\n> line one\n> line two\n\nsuffix') + expect(result.newCursor).toBe('prefix \n\n> line one\n> line two'.length) + }) + + it('clamps an out-of-range cursor to the text length', () => { + const result = buildQuotePatch('abc', 99, 'quoted') + expect(result.newText).toBe('abc\n\n> quoted') + expect(result.newCursor).toBe('abc\n\n> quoted'.length) + }) + + it('handles multi-line selections that already include internal newlines', () => { + const result = buildQuotePatch('hi ', 3, 'foo\nbar') + expect(result.newText).toBe('hi \n\n> foo\n> bar') + expect(result.newCursor).toBe('hi \n\n> foo\n> bar'.length) + }) +}) + +describe('findTargetTextarea', () => { + it('returns null when selection is null', () => { + expect(findTargetTextarea(null)).toBeNull() + }) + + it('returns null when the selection is not inside any pane', () => { + container.innerHTML = '
no pane here
' + const anchor = container.querySelector('div') + const sel = makeFakeSelection({ anchorElement: anchor }) + expect(findTargetTextarea(sel as Selection)).toBeNull() + }) + + it('returns the textarea inside the closest data-pane-id ancestor', () => { + container.innerHTML = ` +
+

inside pane a

+ +
+ ` + const anchor = container.querySelector('p') + const sel = makeFakeSelection({ anchorElement: anchor }) + const textarea = findTargetTextarea(sel as Selection) + expect(textarea).toBe(container.querySelector('textarea')) + }) + + it('walks up to a shared pane ancestor when textarea lives in a sibling subtree', () => { + // Real ChatPane DOM: messages and the input live in different subtrees + // under the same data-pane-id wrapper. Selection deep inside a message + // must still resolve to the input textarea in the same pane. + container.innerHTML = ` +
+
+

a thought provoking sentence worth quoting

+
+
+
+ +
+
+
+ ` + const anchor = container.querySelector('#msg') + const sel = makeFakeSelection({ anchorElement: anchor }) + const textarea = findTargetTextarea(sel as Selection) + expect(textarea).toBe(container.querySelector('textarea')) + }) +}) + +describe('shouldShowPopupForSelection', () => { + it('rejects null selections', () => { + expect(shouldShowPopupForSelection(null)).toBe(false) + }) + + it('rejects collapsed selections', () => { + container.innerHTML = ` +
+

just sitting here

+
+ ` + const anchor = container.querySelector('p') + const sel = makeFakeSelection({ anchorElement: anchor, isCollapsed: true, text: 'something' }) + expect(shouldShowPopupForSelection(sel as Selection)).toBe(false) + }) + + it('rejects selections whose text is whitespace only', () => { + container.innerHTML = ` +
+

content

+
+ ` + const anchor = container.querySelector('p') + const sel = makeFakeSelection({ anchorElement: anchor, text: ' ' }) + expect(shouldShowPopupForSelection(sel as Selection)).toBe(false) + }) + + it('rejects selections inside an input/textarea', () => { + container.innerHTML = ` +
+ +
+ ` + const textarea = container.querySelector('textarea')! + const sel = makeFakeSelection({ anchorElement: textarea, text: 'user draft' }) + expect(shouldShowPopupForSelection(sel as Selection)).toBe(false) + }) + + it('rejects selections inside elements marked with data-no-selection-popup', () => { + container.innerHTML = ` +
+
opt-out
+
+ ` + const anchor = container.querySelector('span') + const sel = makeFakeSelection({ anchorElement: anchor, text: 'opt-out' }) + expect(shouldShowPopupForSelection(sel as Selection)).toBe(false) + }) + + it('accepts ordinary text inside a pane', () => { + container.innerHTML = ` +
+

an interesting fact

+
+ ` + const anchor = container.querySelector('p') + const sel = makeFakeSelection({ anchorElement: anchor, text: 'an interesting fact' }) + expect(shouldShowPopupForSelection(sel as Selection)).toBe(true) + }) +}) + +describe('computePopupPosition', () => { + // The function takes a simple anchor {top, bottom, left} that works for + // both pointer-release coords (top==bottom==y) and selection rects. + function anchor(top: number, bottom: number, left: number) { + return { top, bottom, left } + } + + it('places above when there is room', () => { + const result = computePopupPosition(anchor(200, 220, 50), 40, 200, 1024, 8, 800) + expect(result.placement).toBe('above') + expect(result.top).toBe(152) // 200 - 40 - 8 + expect(result.left).toBe(50) + }) + + it('places below when above would clip', () => { + const result = computePopupPosition(anchor(20, 30, 50), 40, 200, 1024, 8, 800) + expect(result.placement).toBe('below') + expect(result.top).toBe(38) // 30 + 8 + }) + + it('clamps to the right edge of the viewport', () => { + const result = computePopupPosition(anchor(200, 220, 900), 40, 200, 1024, 8, 800) + expect(result.left).toBeLessThanOrEqual(1024 - 200 - 8) + }) + + it('clamps to the left edge of the viewport', () => { + const result = computePopupPosition(anchor(200, 220, -100), 40, 200, 1024, 8, 800) + expect(result.left).toBe(8) + }) + + it('falls back to above (clamped) when both directions are tight', () => { + const result = computePopupPosition(anchor(2, 4, 0), 40, 200, 1024, 8, 50) + expect(result.placement).toBe('above') + expect(result.top).toBeGreaterThanOrEqual(8) + }) + + it('works with a zero-height anchor (pointer release point)', () => { + const result = computePopupPosition(anchor(200, 200, 100), 30, 180, 1024, 8, 800) + expect(result.placement).toBe('above') + expect(result.top).toBe(162) // 200 - 30 - 8 + expect(result.left).toBe(100) + }) +}) diff --git a/src/features/text-selection-popup/popupUtils.ts b/src/features/text-selection-popup/popupUtils.ts new file mode 100644 index 00000000..95f036ee --- /dev/null +++ b/src/features/text-selection-popup/popupUtils.ts @@ -0,0 +1,147 @@ +/** + * Pure helpers for the text-selection floating popup. Kept dependency-free so + * they can be unit-tested without a DOM or React runtime. + */ + +/** + * Format a selection as a Markdown blockquote prefix. Each line gets a `> ` + * (or a single `>` for blank lines), matching the convention used by GitHub, + * Slack and other Markdown dialects. + */ +export function formatQuote(text: string): string { + if (text.length === 0) return '' + return text + .split('\n') + .map(line => (line.length > 0 ? `> ${line}` : '>')) + .join('\n') +} + +/** + * Count the newlines needed before the quote so it lands on its own blank + * line in the rendered Markdown. + * - empty `before` → 0 (handled separately) + * - ends with `\n\n`+ → 0 (blank line already exists) + * - ends with `\n` → 1 (add one more for a blank line) + * - ends with non-newline → 2 (full blank line) + */ +function leadNewlinesFor(before: string): string { + if (before.length === 0) return '' + if (/\n\s*\n\s*$/.test(before)) return '' + if (/\n\s*$/.test(before)) return '\n' + return '\n\n' +} + +/** Symmetric trailing-newline counterpart to {@link leadNewlinesFor}. */ +function trailingNewlinesFor(after: string): string { + if (after.length === 0) return '' + if (/^\s*\n\s*\n/.test(after)) return '' + if (/^\s*\n/.test(after)) return '\n' + return '\n\n' +} + +/** + * Build a text patch that inserts a quoted version of `selected` into + * `existing` at `cursor`, returning the next text + the cursor position right + * after the insertion (so the user can continue typing). + */ +export function buildQuotePatch( + existing: string, + cursor: number, + selected: string, +): { newText: string; newCursor: number } { + const safeCursor = Math.max(0, Math.min(cursor, existing.length)) + const before = existing.slice(0, safeCursor) + const after = existing.slice(safeCursor) + const quoted = formatQuote(selected) + + const beforeChunk = `${before}${leadNewlinesFor(before)}` + const middleSep = before.length === 0 ? '\n\n' : '' // empty input → cursor lands on fresh line + const afterChunk = `${middleSep}${trailingNewlinesFor(after)}${after}` + + const newText = `${beforeChunk}${quoted}${afterChunk}` + const newCursor = beforeChunk.length + quoted.length + + return { newText, newCursor } +} + +/** + * Locate the target ` +
+ ` + const anchor = container.querySelector('#anchor-text')!.firstChild! + const range = document.createRange() + range.setStart(anchor, 0) + range.setEnd(anchor, anchor.textContent!.length) + + // jsdom doesn't compute layout, so getBoundingClientRect returns 0s. The + // popup relies on a real rect to position itself, so override it for the + // synthetic range. In a real browser this stays untouched. + range.getBoundingClientRect = () => + ({ + top: 100, + left: 200, + right: 300, + bottom: 120, + width: 100, + height: 20, + x: 200, + y: 100, + toJSON: () => ({}), + }) as DOMRect + + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + return { + textarea: container.querySelector('[data-testid="textarea"]')!, + selection, + range, + } +} + +function releasePointer(clientX = 250, clientY = 110) { + fireEvent.mouseUp(document.body, { clientX, clientY }) +} + +async function flush() { + await act(async () => { + await new Promise(r => setTimeout(r, 50)) + }) +} + +describe('TextSelectionPopup', () => { + it('does NOT appear during a selection drag — only after mouseup commits', async () => { + setupSelectionPane('hello there') + + render() + await flush() + + // selectionchange ran mid-test setup; no mouseup yet → popup must be hidden. + expect(document.body.querySelector('[data-text-selection-popup]')).toBeNull() + + await act(async () => { + releasePointer(280, 140) + }) + + expect(document.body.querySelector('[data-text-selection-popup]')).not.toBeNull() + }) + + it('renders Quote and Copy buttons after mouseup over a valid selection', async () => { + setupSelectionPane('hello there') + render() + await flush() + await act(async () => { + releasePointer() + }) + + const popup = document.body.querySelector('[data-text-selection-popup]') + expect(popup).not.toBeNull() + const quoteButton = popup?.querySelector('button[aria-label="Quote"]') + const copyButton = popup?.querySelector('button[aria-label="Copy"]') + expect(quoteButton).not.toBeNull() + expect(copyButton).not.toBeNull() + }) + + it('does not appear for selections outside any data-pane-id container', async () => { + container.innerHTML = ` +
plain outside pane + ${'outside'} +
+ ` + const anchor = container.querySelector('#anchor-text')!.firstChild! + const range = document.createRange() + range.setStart(anchor, 0) + range.setEnd(anchor, anchor.textContent!.length) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + + render() + await flush() + await act(async () => { + releasePointer() + }) + + expect(document.body.querySelector('[data-text-selection-popup]')).toBeNull() + }) + + it('Quote writes a markdown block-quote into the pane textarea and focuses it', async () => { + const { textarea } = setupSelectionPane('how does this work?') + + render() + await flush() + await act(async () => { + releasePointer() + }) + + const quoteButton = document.body.querySelector('button[aria-label="Quote"]') as HTMLButtonElement + expect(quoteButton).not.toBeNull() + + act(() => { + fireEvent.click(quoteButton) + }) + + expect(textarea.value.startsWith('> how does this work?')).toBe(true) + expect(COPY_MOCK).not.toHaveBeenCalled() + }) + + it('Copy writes the selection to the clipboard without touching the textarea', async () => { + const { textarea } = setupSelectionPane('plain copy text') + render() + await flush() + await act(async () => { + releasePointer() + }) + + const copyButton = document.body.querySelector('button[aria-label="Copy"]') as HTMLButtonElement + expect(copyButton).not.toBeNull() + + await act(async () => { + fireEvent.click(copyButton) + }) + + expect(COPY_MOCK).toHaveBeenCalledWith('plain copy text') + expect(textarea.value).toBe('') + }) + + it('hides on Escape after a mouseup-committed selection', async () => { + setupSelectionPane('escape me') + render() + await flush() + await act(async () => { + releasePointer() + }) + expect(document.body.querySelector('[data-text-selection-popup]')).not.toBeNull() + + await act(async () => { + fireEvent.keyDown(document, { key: 'Escape' }) + }) + + expect(document.body.querySelector('[data-text-selection-popup]')).toBeNull() + }) + + it('hides on scroll after a mouseup-committed selection', async () => { + setupSelectionPane('scroll away') + render() + await flush() + await act(async () => { + releasePointer() + }) + expect(document.body.querySelector('[data-text-selection-popup]')).not.toBeNull() + + await act(async () => { + window.dispatchEvent(new Event('scroll')) + }) + expect(document.body.querySelector('[data-text-selection-popup]')).toBeNull() + }) + + it('places the popup near the pointer release point, not the selection start', async () => { + setupSelectionPane('anchor for position math') + render() + await flush() + await act(async () => { + // Release at coordinates far from the synthetic selection rect + // (rect is at top=100/left=200 — pick something the rect-aware path + // would never choose). + releasePointer(900, 600) + }) + + const popup = document.body.querySelector('[data-text-selection-popup]') + expect(popup).not.toBeNull() + // Read the inline style values applied by computePopupAtPointer. + expect(popup!.style.position).toBe('fixed') + expect(popup!.style.top).toMatch(/^\d+(\.\d+)?px$/) + expect(popup!.style.left).toMatch(/^\d+(\.\d+)?px$/) + const leftPx = Number.parseFloat(popup!.style.left) + // Should not coincide with the synthetic rect's left (200). + expect(leftPx).not.toBe(200) + // 900 - popupWidth estimate (200) places the cursor's left edge of the popup. + expect(leftPx).toBeGreaterThan(500) + }) +}) diff --git a/src/features/text-selection-popup/TextSelectionPopup.tsx b/src/features/text-selection-popup/TextSelectionPopup.tsx new file mode 100644 index 00000000..9f10f3f7 --- /dev/null +++ b/src/features/text-selection-popup/TextSelectionPopup.tsx @@ -0,0 +1,272 @@ +/** + * TextSelectionPopup — global floating popup that surfaces "Quote" and "Copy" + * actions when the user highlights text inside a chat pane. + * + * - selectionchange tracks a *pending* selection into a ref. The popup + * never appears during a drag — it only commits on mouseup / touchend / + * keyboard Shift+arrow release so the toolbar never flickers mid-drag. + * - Positions near the pointer release point (mouse / touch), or near the + * selection's bounding rect for keyboard selections. + * - Renders into document.body via createPortal so its fixed position is + * unaffected by ancestor transform / overflow. + * - Auto-hides on: outside pointerdown, scroll, resize, Esc. + */ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import { CheckIcon, CopyIcon, QuoteIcon } from '../../components/Icons' +import { copyTextToClipboard } from '../../utils/clipboard' +import { clipboardErrorHandler } from '../../utils/errorHandling' +import { + buildQuotePatch, + computePopupPosition, + findTargetTextarea, + shouldShowPopupForSelection, +} from './popupUtils' + +const POPUP_WIDTH_ESTIMATE = 180 +const POPUP_HEIGHT_ESTIMATE = 30 +const COPIED_FEEDBACK_MS = 1500 + +type PendingSelection = { + text: string + rect: DOMRect + textarea: HTMLTextAreaElement +} + +type PlacedPopup = PendingSelection & { + /** Pointer release point in viewport coordinates; null for keyboard selection. */ + pointer: { x: number; y: number } | null +} + +export function TextSelectionPopup() { + const { t } = useTranslation('chat') + const [placed, setPlaced] = useState(null) + const [copied, setCopied] = useState(false) + const [popupWidth, setPopupWidth] = useState(POPUP_WIDTH_ESTIMATE) + const [popupHeight, setPopupHeight] = useState(POPUP_HEIGHT_ESTIMATE) + const popupRef = useRef(null) + const copiedTimerRef = useRef | null>(null) + /** + * Latest valid selection, kept current via selectionchange. The popup + * never renders from this — only from mouseup/touchend/keyup commits. + */ + const pendingRef = useRef(null) + + // ── selectionchange: maintain pending ref only, never show popup ── + useEffect(() => { + if (typeof document === 'undefined') return + const handler = () => { + const selection = window.getSelection() + if (!selection || !shouldShowPopupForSelection(selection)) { + pendingRef.current = null + return + } + const textarea = findTargetTextarea(selection) + const range = selection.getRangeAt(0) + const rect = range.getBoundingClientRect() + // Zero-by-zero rect = browser hasn't laid out the selection (display:none + // content, or the jsdom test env). The toolbar needs real geometry. + if (!textarea || (rect.width === 0 && rect.height === 0)) { + pendingRef.current = null + return + } + pendingRef.current = { text: selection.toString(), rect, textarea } + } + document.addEventListener('selectionchange', handler) + return () => document.removeEventListener('selectionchange', handler) + }, []) + + // ── pointerdown outside popup: dismiss + clear pending ── + useEffect(() => { + if (typeof document === 'undefined') return + const handler = (e: PointerEvent) => { + const popup = popupRef.current + if (popup && e.target instanceof Node && popup.contains(e.target)) return + setPlaced(null) + pendingRef.current = null + } + document.addEventListener('pointerdown', handler) + return () => document.removeEventListener('pointerdown', handler) + }, []) + + // ── mouseup / touchend: commit pending + position at pointer ── + useEffect(() => { + if (typeof document === 'undefined') return + const commit = (pointer: { x: number; y: number } | null) => { + const pending = pendingRef.current + if (!pending) return + setPlaced({ ...pending, pointer }) + } + const onMouseUp = (e: MouseEvent) => commit({ x: e.clientX, y: e.clientY }) + const onTouchEnd = (e: TouchEvent) => { + const t = e.changedTouches[0] + if (t) commit({ x: t.clientX, y: t.clientY }) + } + document.addEventListener('mouseup', onMouseUp) + document.addEventListener('touchend', onTouchEnd) + return () => { + document.removeEventListener('mouseup', onMouseUp) + document.removeEventListener('touchend', onTouchEnd) + } + }, []) + + // ── keyboard Shift+arrow: commit pending, no pointer coords ── + useEffect(() => { + if (typeof document === 'undefined') return + const SELECTION_KEYS = new Set([ + 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', + 'Home', 'End', 'PageUp', 'PageDown', + ]) + const handler = (e: KeyboardEvent) => { + if (!e.shiftKey || !SELECTION_KEYS.has(e.key)) return + const pending = pendingRef.current + if (!pending) return + setPlaced({ ...pending, pointer: null }) + } + document.addEventListener('keyup', handler) + return () => document.removeEventListener('keyup', handler) + }, []) + + // ── dismiss on scroll / resize / Escape (only while popup is open) ── + useEffect(() => { + if (!placed) return + const hide = () => setPlaced(null) + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setPlaced(null) + window.getSelection()?.removeAllRanges() + } + } + window.addEventListener('scroll', hide, true) + window.addEventListener('resize', hide) + document.addEventListener('keydown', onKey) + return () => { + window.removeEventListener('scroll', hide, true) + window.removeEventListener('resize', hide) + document.removeEventListener('keydown', onKey) + } + }, [placed]) + + // ── measure actual popup size for accurate positioning ── + useLayoutEffect(() => { + if (!placed) return + const popup = popupRef.current + if (!popup) return + const ro = new ResizeObserver(entries => { + for (const entry of entries) { + const { width, height } = entry.contentRect + if (width > 0) setPopupWidth(width) + if (height > 0) setPopupHeight(height) + } + }) + ro.observe(popup) + return () => ro.disconnect() + }, [placed]) + + // ── cleanup copied feedback timer on unmount ── + useEffect(() => { + return () => { + if (copiedTimerRef.current !== null) clearTimeout(copiedTimerRef.current) + } + }, []) + + const handleCopy = useCallback(async () => { + if (!placed) return + try { + await copyTextToClipboard(placed.text) + setCopied(true) + if (copiedTimerRef.current !== null) clearTimeout(copiedTimerRef.current) + copiedTimerRef.current = setTimeout(() => { + setCopied(false) + copiedTimerRef.current = null + }, COPIED_FEEDBACK_MS) + } catch (err) { + clipboardErrorHandler('copy selection', err) + } + }, [placed]) + + const handleQuote = useCallback(() => { + if (!placed) return + const textarea = placed.textarea + const existing = textarea.value + const cursor = typeof textarea.selectionStart === 'number' ? textarea.selectionStart : existing.length + const { newText, newCursor } = buildQuotePatch(existing, cursor, placed.text) + + // Drive the textarea via its native value setter so React sees a real + // input event and syncs the controlled state on the next render. + const valueSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(textarea), 'value')?.set + if (valueSetter) valueSetter.call(textarea, newText) + textarea.dispatchEvent(new Event('input', { bubbles: true })) + + requestAnimationFrame(() => { + textarea.focus() + textarea.setSelectionRange(newCursor, newCursor) + }) + + window.getSelection()?.removeAllRanges() + setPlaced(null) + }, [placed]) + + // Position: use pointer coords when available (mouse/touch), otherwise + // the selection's bounding rect (keyboard). + const position = useMemo(() => { + if (!placed) return null + const anchor = placed.pointer + ? { top: placed.pointer.y, bottom: placed.pointer.y, left: placed.pointer.x } + : { top: placed.rect.top, bottom: placed.rect.bottom, left: placed.rect.left } + const vw = typeof window !== 'undefined' ? window.innerWidth : 1024 + return computePopupPosition(anchor, popupHeight, popupWidth, vw) + }, [placed, popupHeight, popupWidth]) + + if (!placed || !position) return null + + return createPortal( +
e.preventDefault()} + > +
+ + +
, + document.body, + ) +} diff --git a/src/features/text-selection-popup/index.ts b/src/features/text-selection-popup/index.ts new file mode 100644 index 00000000..a3b13cec --- /dev/null +++ b/src/features/text-selection-popup/index.ts @@ -0,0 +1 @@ +export { TextSelectionPopup } from './TextSelectionPopup' diff --git a/src/features/text-selection-popup/popupUtils.test.ts b/src/features/text-selection-popup/popupUtils.test.ts new file mode 100644 index 00000000..c83deeab --- /dev/null +++ b/src/features/text-selection-popup/popupUtils.test.ts @@ -0,0 +1,252 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + buildQuotePatch, + computePopupPosition, + findTargetTextarea, + formatQuote, + shouldShowPopupForSelection, +} from './popupUtils' + +let container: HTMLDivElement + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) +}) + +afterEach(() => { + document.body.removeChild(container) +}) + +/** + * Build a minimal fake `Selection` for `findTargetTextarea` / `shouldShow*`. + * jsdom doesn't implement window.getSelection in a useful way, so we unit-test + * the helpers with stub objects that quack like a Selection. + */ +function makeFakeSelection(opts: { + anchorElement: Element | null + text?: string + isCollapsed?: boolean + rangeCount?: number +}): Partial { + const rangeCount = opts.rangeCount ?? 1 + return { + anchorNode: opts.anchorElement, + isCollapsed: opts.isCollapsed ?? false, + rangeCount, + toString: () => opts.text ?? '', + getRangeAt: () => ({}) as Range, + } +} + +describe('formatQuote', () => { + it('prefixes a single line with "> "', () => { + expect(formatQuote('hello world')).toBe('> hello world') + }) + + it('prefixes each line of a multi-line selection', () => { + expect(formatQuote('line one\nline two\nline three')).toBe('> line one\n> line two\n> line three') + }) + + it('emits a lone ">" for blank lines so Markdown stays a valid blockquote', () => { + expect(formatQuote('first\n\nthird')).toBe('> first\n>\n> third') + }) + + it('returns an empty string for empty input', () => { + expect(formatQuote('')).toBe('') + }) +}) + +describe('buildQuotePatch', () => { + it('quotes into an empty input and places the cursor at the end of the quote', () => { + const result = buildQuotePatch('', 0, 'first line\nsecond line') + expect(result.newText).toBe('> first line\n> second line\n\n') + expect(result.newCursor).toBe('> first line\n> second line'.length) + }) + + it('quotes at the end of a non-empty input that already ends with a newline', () => { + const existing = 'Please look at this:\n' + const result = buildQuotePatch(existing, existing.length, 'quoted block') + // Single trailing newline → add one more so the quote sits on a blank line. + expect(result.newText).toBe('Please look at this:\n\n> quoted block') + expect(result.newCursor).toBe(result.newText.length) + }) + + it('does not double-add a separator when the input already ends with a blank line', () => { + const existing = 'Already has a blank line:\n\n' + const result = buildQuotePatch(existing, existing.length, 'quoted block') + expect(result.newText).toBe('Already has a blank line:\n\n> quoted block') + }) + + it('quotes in the middle of a non-empty input while preserving surrounding text', () => { + const existing = 'prefix suffix' + const result = buildQuotePatch(existing, 'prefix '.length, 'line one\nline two') + expect(result.newText).toBe('prefix \n\n> line one\n> line two\n\nsuffix') + expect(result.newCursor).toBe('prefix \n\n> line one\n> line two'.length) + }) + + it('clamps an out-of-range cursor to the text length', () => { + const result = buildQuotePatch('abc', 99, 'quoted') + expect(result.newText).toBe('abc\n\n> quoted') + expect(result.newCursor).toBe('abc\n\n> quoted'.length) + }) + + it('handles multi-line selections that already include internal newlines', () => { + const result = buildQuotePatch('hi ', 3, 'foo\nbar') + expect(result.newText).toBe('hi \n\n> foo\n> bar') + expect(result.newCursor).toBe('hi \n\n> foo\n> bar'.length) + }) +}) + +describe('findTargetTextarea', () => { + it('returns null when selection is null', () => { + expect(findTargetTextarea(null)).toBeNull() + }) + + it('returns null when the selection is not inside any pane', () => { + container.innerHTML = '
no pane here
' + const anchor = container.querySelector('div') + const sel = makeFakeSelection({ anchorElement: anchor }) + expect(findTargetTextarea(sel as Selection)).toBeNull() + }) + + it('returns the textarea inside the closest data-pane-id ancestor', () => { + container.innerHTML = ` +
+

inside pane a

+ +
+ ` + const anchor = container.querySelector('p') + const sel = makeFakeSelection({ anchorElement: anchor }) + const textarea = findTargetTextarea(sel as Selection) + expect(textarea).toBe(container.querySelector('textarea')) + }) + + it('walks up to a shared pane ancestor when textarea lives in a sibling subtree', () => { + // Real ChatPane DOM: messages and the input live in different subtrees + // under the same data-pane-id wrapper. Selection deep inside a message + // must still resolve to the input textarea in the same pane. + container.innerHTML = ` +
+
+

a thought provoking sentence worth quoting

+
+
+
+ +
+
+
+ ` + const anchor = container.querySelector('#msg') + const sel = makeFakeSelection({ anchorElement: anchor }) + const textarea = findTargetTextarea(sel as Selection) + expect(textarea).toBe(container.querySelector('textarea')) + }) +}) + +describe('shouldShowPopupForSelection', () => { + it('rejects null selections', () => { + expect(shouldShowPopupForSelection(null)).toBe(false) + }) + + it('rejects collapsed selections', () => { + container.innerHTML = ` +
+

just sitting here

+
+ ` + const anchor = container.querySelector('p') + const sel = makeFakeSelection({ anchorElement: anchor, isCollapsed: true, text: 'something' }) + expect(shouldShowPopupForSelection(sel as Selection)).toBe(false) + }) + + it('rejects selections whose text is whitespace only', () => { + container.innerHTML = ` +
+

content

+
+ ` + const anchor = container.querySelector('p') + const sel = makeFakeSelection({ anchorElement: anchor, text: ' ' }) + expect(shouldShowPopupForSelection(sel as Selection)).toBe(false) + }) + + it('rejects selections inside an input/textarea', () => { + container.innerHTML = ` +
+ +
+ ` + const textarea = container.querySelector('textarea')! + const sel = makeFakeSelection({ anchorElement: textarea, text: 'user draft' }) + expect(shouldShowPopupForSelection(sel as Selection)).toBe(false) + }) + + it('rejects selections inside elements marked with data-no-selection-popup', () => { + container.innerHTML = ` +
+
opt-out
+
+ ` + const anchor = container.querySelector('span') + const sel = makeFakeSelection({ anchorElement: anchor, text: 'opt-out' }) + expect(shouldShowPopupForSelection(sel as Selection)).toBe(false) + }) + + it('accepts ordinary text inside a pane', () => { + container.innerHTML = ` +
+

an interesting fact

+
+ ` + const anchor = container.querySelector('p') + const sel = makeFakeSelection({ anchorElement: anchor, text: 'an interesting fact' }) + expect(shouldShowPopupForSelection(sel as Selection)).toBe(true) + }) +}) + +describe('computePopupPosition', () => { + // The function takes a simple anchor {top, bottom, left} that works for + // both pointer-release coords (top==bottom==y) and selection rects. + function anchor(top: number, bottom: number, left: number) { + return { top, bottom, left } + } + + it('places above when there is room', () => { + const result = computePopupPosition(anchor(200, 220, 50), 40, 200, 1024, 8, 800) + expect(result.placement).toBe('above') + expect(result.top).toBe(152) // 200 - 40 - 8 + expect(result.left).toBe(50) + }) + + it('places below when above would clip', () => { + const result = computePopupPosition(anchor(20, 30, 50), 40, 200, 1024, 8, 800) + expect(result.placement).toBe('below') + expect(result.top).toBe(38) // 30 + 8 + }) + + it('clamps to the right edge of the viewport', () => { + const result = computePopupPosition(anchor(200, 220, 900), 40, 200, 1024, 8, 800) + expect(result.left).toBeLessThanOrEqual(1024 - 200 - 8) + }) + + it('clamps to the left edge of the viewport', () => { + const result = computePopupPosition(anchor(200, 220, -100), 40, 200, 1024, 8, 800) + expect(result.left).toBe(8) + }) + + it('falls back to above (clamped) when both directions are tight', () => { + const result = computePopupPosition(anchor(2, 4, 0), 40, 200, 1024, 8, 50) + expect(result.placement).toBe('above') + expect(result.top).toBeGreaterThanOrEqual(8) + }) + + it('works with a zero-height anchor (pointer release point)', () => { + const result = computePopupPosition(anchor(200, 200, 100), 30, 180, 1024, 8, 800) + expect(result.placement).toBe('above') + expect(result.top).toBe(162) // 200 - 30 - 8 + expect(result.left).toBe(100) + }) +}) diff --git a/src/features/text-selection-popup/popupUtils.ts b/src/features/text-selection-popup/popupUtils.ts new file mode 100644 index 00000000..95f036ee --- /dev/null +++ b/src/features/text-selection-popup/popupUtils.ts @@ -0,0 +1,147 @@ +/** + * Pure helpers for the text-selection floating popup. Kept dependency-free so + * they can be unit-tested without a DOM or React runtime. + */ + +/** + * Format a selection as a Markdown blockquote prefix. Each line gets a `> ` + * (or a single `>` for blank lines), matching the convention used by GitHub, + * Slack and other Markdown dialects. + */ +export function formatQuote(text: string): string { + if (text.length === 0) return '' + return text + .split('\n') + .map(line => (line.length > 0 ? `> ${line}` : '>')) + .join('\n') +} + +/** + * Count the newlines needed before the quote so it lands on its own blank + * line in the rendered Markdown. + * - empty `before` → 0 (handled separately) + * - ends with `\n\n`+ → 0 (blank line already exists) + * - ends with `\n` → 1 (add one more for a blank line) + * - ends with non-newline → 2 (full blank line) + */ +function leadNewlinesFor(before: string): string { + if (before.length === 0) return '' + if (/\n\s*\n\s*$/.test(before)) return '' + if (/\n\s*$/.test(before)) return '\n' + return '\n\n' +} + +/** Symmetric trailing-newline counterpart to {@link leadNewlinesFor}. */ +function trailingNewlinesFor(after: string): string { + if (after.length === 0) return '' + if (/^\s*\n\s*\n/.test(after)) return '' + if (/^\s*\n/.test(after)) return '\n' + return '\n\n' +} + +/** + * Build a text patch that inserts a quoted version of `selected` into + * `existing` at `cursor`, returning the next text + the cursor position right + * after the insertion (so the user can continue typing). + */ +export function buildQuotePatch( + existing: string, + cursor: number, + selected: string, +): { newText: string; newCursor: number } { + const safeCursor = Math.max(0, Math.min(cursor, existing.length)) + const before = existing.slice(0, safeCursor) + const after = existing.slice(safeCursor) + const quoted = formatQuote(selected) + + const beforeChunk = `${before}${leadNewlinesFor(before)}` + const middleSep = before.length === 0 ? '\n\n' : '' // empty input → cursor lands on fresh line + const afterChunk = `${middleSep}${trailingNewlinesFor(after)}${after}` + + const newText = `${beforeChunk}${quoted}${afterChunk}` + const newCursor = beforeChunk.length + quoted.length + + return { newText, newCursor } +} + +/** + * Locate the target `