feat(text-selection): 选中文字弹出引用/复制浮窗 - #149
Open
SsparKluo wants to merge 27 commits into
Open
Conversation
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
force-pushed
the
feat/text-selection-floating-popup
branch
from
July 29, 2026 10:43
51a0ec6 to
f713fd1
Compare
- 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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
概述
在聊天消息区域选中文字后,弹出悬浮工具栏,提供「引用」和「复制」两个操作。
>),插入到当前对话面板的输入框光标位置,并自动聚焦输入框实现细节
交互流程
selectionchange事件实时追踪选区状态到 ref,但不显示浮窗mouseup/touchend/ 键盘Shift+方向键释放时才提交显示——避免拖选过程中浮窗闪烁Esc均自动关闭浮窗定位策略
clientX/Y作为锚点,浮窗显示在指针上方Pane 定位
在
ChatPane根元素上添加了data-pane-id属性。这是必要的——消息 DOM 和输入框是 ChatPane 内部的兄弟子树,之前data-pane-id只挂在 InputBox 容器上,导致从消息文字向上closest('[data-pane-id]')找不到 pane 祖先。现在两者共享同一个data-pane-id祖先,split 模式下也能正确路由到对应 pane 的输入框。过滤规则
以下场景不弹出浮窗:
input、textarea、[contenteditable="true"]内[data-no-selection-popup]标记的区域内data-pane-id容器内(如侧边栏、终端)引用块格式化
>前缀,空行变为单独的>,保持 Markdown blockquote 语义\n,确保引用块始终独占一段(对齐 Slack / Notion 行为)技术实现
App根级,通过createPortal渲染到document.body,position: fixed定位不受父级 transform / overflow 影响valuesetter +dispatchEvent(new Event('input'))驱动,React 受控组件正常同步QuoteIcon复用 lucide-react 已有的Quote图标,通过Icons.tsxbarrel 统一导出en和zh-CN文件变更
src/features/text-selection-popup/src/App.tsx<TextSelectionPopup />src/features/chat/ChatPane.tsxdata-pane-idsrc/components/Icons.tsxQuoteIconsrc/locales/{en,zh-CN}/chat.jsontextSelectionPopupi18n keys测试
typecheck/lint/build全绿截图