Skip to content

feat(text-selection): 选中文字弹出引用/复制浮窗 - #149

Open
SsparKluo wants to merge 27 commits into
lehhair:mainfrom
SsparKluo:feat/text-selection-floating-popup
Open

feat(text-selection): 选中文字弹出引用/复制浮窗#149
SsparKluo wants to merge 27 commits into
lehhair:mainfrom
SsparKluo:feat/text-selection-floating-popup

Conversation

@SsparKluo

@SsparKluo SsparKluo commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

概述

在聊天消息区域选中文字后,弹出悬浮工具栏,提供「引用」和「复制」两个操作。

  • 引用:将选中的文字转为 Markdown 引用块(> ),插入到当前对话面板的输入框光标位置,并自动聚焦输入框
  • 复制:将选中的文字复制到剪贴板,按钮短暂显示「已复制」反馈

实现细节

交互流程

  1. selectionchange 事件实时追踪选区状态到 ref,但不显示浮窗
  2. mouseup / touchend / 键盘 Shift+方向键 释放时才提交显示——避免拖选过程中浮窗闪烁
  3. 浮窗定位在鼠标释放点附近(而非选区起始端),模仿浏览器原生选区工具栏的行为
  4. 点击外部、滚动、缩放窗口、按 Esc 均自动关闭浮窗

定位策略

  • 鼠标/触摸:使用 pointerup 的 clientX/Y 作为锚点,浮窗显示在指针上方
  • 键盘选择:回退到选区的 bounding rect
  • 水平方向自动 clamp 到视口范围内,垂直方向在空间不足时自动翻转到下方

Pane 定位

ChatPane 根元素上添加了 data-pane-id 属性。这是必要的——消息 DOM 和输入框是 ChatPane 内部的兄弟子树,之前 data-pane-id 只挂在 InputBox 容器上,导致从消息文字向上 closest('[data-pane-id]') 找不到 pane 祖先。现在两者共享同一个 data-pane-id 祖先,split 模式下也能正确路由到对应 pane 的输入框。

过滤规则

以下场景不弹出浮窗:

  • 选区折叠(无实际选中内容)
  • 选区在 inputtextarea[contenteditable="true"]
  • 选区在 [data-no-selection-popup] 标记的区域内
  • 选区不在任何 data-pane-id 容器内(如侧边栏、终端)

引用块格式化

  • 每行加 > 前缀,空行变为单独的 >,保持 Markdown blockquote 语义
  • 插入位置前后智能补 \n,确保引用块始终独占一段(对齐 Slack / Notion 行为)

技术实现

  • 组件挂在 App 根级,通过 createPortal 渲染到 document.bodyposition: fixed 定位不受父级 transform / overflow 影响
  • Quote 操作通过 textarea 的原生 value setter + dispatchEvent(new Event('input')) 驱动,React 受控组件正常同步
  • QuoteIcon 复用 lucide-react 已有的 Quote 图标,通过 Icons.tsx barrel 统一导出
  • i18n 覆盖 enzh-CN

文件变更

文件 说明
src/features/text-selection-popup/ 新 feature 目录(组件 + 工具函数 + 测试)
src/App.tsx 挂载 <TextSelectionPopup />
src/features/chat/ChatPane.tsx 根元素加 data-pane-id
src/components/Icons.tsx QuoteIcon
src/locales/{en,zh-CN}/chat.json textSelectionPopup i18n keys

测试

  • 34 个新增测试(工具函数 + 组件行为)
  • 全项目 607 个测试全部通过
  • typecheck / lint / build 全绿

截图

image

SsparKluo added 23 commits July 19, 2026 20:51
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 (00a7ccc) 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
Upstream (v0.6.33 → v0.6.34) merged in:
- PR lehhair#142 (feat/code-block): user-configurable Shiki theme (originally our PR)
- Settings dialog search + navigation + config editor drilldown
- Sidebar UI tweaks (control interactions, folder drag-sort fix)
- Settings: align CSS override toolbar
- Markdown: 思考折叠时渲染单行预览
- Settings: default chat-related values
- Various settings dialog component refactors

Conflict resolution:
- CodeBlockThemeSettings.tsx, themeStore.ts, locales, code-block-related
  code: take upstream — the merged PR lehhair#142 is the canonical implementation
- ServersSettings.tsx: upstream rewrite + re-apply fork 'canDeleteDefault'
  (allow deleting default server on non-Tauri when others exist)
- AppearanceSettings.tsx: take upstream import additions
  (DropdownMenu, MenuItem, SettingField, etc.)
- SidePanel.tsx: drop upstream project selector dropdown + search input
  (fork customization: single 'add workspace' button next to New Chat)
  — also drop now-unused refs/state (projectsExpanded, searchInputRef, ...)
- SidebarFooter.tsx: keep fork's slim cog+dot design — context stats stay
  in InputToolbar via ContextUsageButton (fork customization)
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.
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)
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.
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.
…tion-popup, queue-message-bar and collateral fixes

- Rewrite 3.1 scroll system to document input-event-driven v2 redesign
- Add 3.10 text-selection-floating-popup (Quote/Copy)
- Add 3.11 queue-message-input-bar (QueuedMessagesBar)
- Update 3.2 mobile collapse with dock pointer-events fixes
- Update 3.1 commits with capsule scroll, entry-grow fixes
- Bump base reference to v0.6.34 (c49fb49 / dev c6898e6)
- Sync overview table, hot files, and post-merge checklist
…s do

Align touch behavior with desktop: a simple tap (touchstart + touchend
with no significant movement) should NOT stop follow — only actual
scroll gestures should.

Changes:
- onTouchStart: removed unconditional stopFollow(); now only records
  start position
- onTouchMove: added direction detection — finger down >10px (scroll
  up gesture) calls stopFollow, matching desktop wheel-up semantics
- onTouchEnd: now checks isRelevantTouch (editable exclusion)
- isRelevantTouch helper shared across all three handlers

Before: ANY touch in chat root (including taps) immediately stopped
follow, causing the toBottom button to appear on every tap.
After: only deliberate upward scroll gestures stop follow; taps and
small movements are ignored, consistent with desktop where clicks
don't affect follow state.

Tests updated: 6 touch tests rewritten (tap no-op, touchmove threshold,
direction-aware stop/recover).
Reverts the fork's 'single Add Workspace button' simplification back to
upstream's project selector dropdown, which includes per-project remove
(trash button) and the Global folder — solving the missing folder-delete
entry point that the simplification had dropped.

SidePanel.tsx and FolderRecentList.tsx taken from upstream (main / v0.6.34);
contextLimit prop stripped from SidePanel→SidebarFooter to preserve the
fork's 'context-usage stays in InputToolbar, not footer' choice.
Draft new chat per folder (draftNewChatSession.ts) re-applied on top of
upstream's FolderRecentList.
Conflicts resolved:
- useAutoScroll: kept dev's input-event-driven impl; upstream's a7d6d75
  fixes (max<=1 wheel guard, isAuto guard removal, autoMark clearing)
  are all structurally covered by dev's redesign
- ReasoningPartView: adopted upstream's MessageExpandPanel, preserved
  dev's onScroll={handleCapsuleScroll} (capsule smart-scroll fix)
- MessageRenderer: merged dev's withStepsScrollLock(!expanded) arg with
  upstream's MSG_SPACING.header className and MessageExpandPanel adoption
Mirrors upstream cbdcc36: streaming growth makes geometric bottom dist
fluctuate → isCollapsed flickers → InputFooter mount/unmount flicker.
ChatPane now passes isFollowing (!userScrolled) to InputBox.isAtBottom
instead of the geometric-dist isAtBottom. Drops the now-dead isAtBottom
state and onAtBottomChange binding. The InputFooter persistent-DOM half
of cbdcc36 (hidden vs conditional unmount) already landed via the merge.
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.
The quote insertion only added a leading blank line; when the cursor sat
at the end of non-empty input nothing followed the blockquote, so typing
afterward appended to the quote's last line instead of starting a reply.

Always emit a trailing blank line (even when nothing follows) and place
the cursor past it, so the user lands on a fresh line ready to type a
reply below the blockquote. Drops the empty-input middleSep special case
in favor of a single lead/trail path.
# Conflicts:
#	src/features/text-selection-popup/popupUtils.test.ts
#	src/features/text-selection-popup/popupUtils.ts
@SsparKluo
SsparKluo force-pushed the feat/text-selection-floating-popup branch from 51a0ec6 to f713fd1 Compare July 29, 2026 10:43
- Revert (edit): each row gets a pencil button that pulls the queued
  item back into the input box for editing and removes it from the queue.
  Reuses the existing revert recovery pipeline (revertedText/
  revertedAttachments + model/agent restoration) via a local
  queuedRevertContent state that ORs into inputRestoreContent.
  Wired through a combined handleClearInputRevert that resets both the
  undo revert and the queue revert on send.

- Reorder: drag-to-reorder the queue via a left grip handle.
  >=2 items and non-sending items are draggable; the hook handles both
  desktop pointer drag and a 400ms long-press on the whole row for
  mobile. useReorderableList is copied locally from FolderRecentList
  (extract to shared later). followupQueueStore.reorder does the
  splice-and-reinsert.
- multi-server infrastructure (per-server SSE, sessionKey, serverWorkspaces, MultiServerFolderList/SearchResults, per-server APIs)

- composite-key permission/session fixes (8 commits)

- markdown robust math + markdownSegments convergence

- getLastTurnDiff bound (limit=INITIAL_MESSAGE_LIMIT), fast-uri CVE-2026-13676, Caddy Authorization forward, IME composition fix, useMobileCollapse justCleared

- streaming perf + DesktopTitlebar self-drawn controls, rightPanel/server follow, path style fixes

Retained fork: input-driven scroll-follow (useAutoScroll 10px), SessionHeaderLocation, InputToolbar model+ContextUsageButton, QueuedMessagesBar, draftNewChat per-folder, text-selection-popup, ServersSettings canDeleteDefault (non-desktop delete default).

Conflicts resolved: ChatArea kept fork scroll (visibleMessageEntries adopted, autoHandleWheel dropped), ChatPane combined paneServerId+queued handlers (isFollowing not isAtBottom), FolderRecentList combined draft+serverId/embedded+activeSessionKey, ServersSettings combined canDeleteDefault+multiServer subscribe toggle. typecheck+test green (668 tests).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant