Skip to content

Commit 7bab3c2

Browse files
committed
fix(desktop): make the selection menu placement configurable
A multi-line text selection puts its action menu in the slot beside where the selection ends. That is the upstream rule from 54f3c00, anchored on the selection focus rect via 63c81e6 — which is exactly where the unread text resumes, so the menu lands on the next thing the reader wants to look at. Add a preference instead of changing the rule. `cc-haha-selection-menu-placement` defaults to `auto`, the upstream slot, and `above` drops the slot so the menu floats over the selection. It keys itself rather than joining the reading preferences in `cc-haha-chat-appearance`: the bubble position is not a reading style, so changing it must not rewrite that record and "Reset chat appearance" must not clear it. Nothing in the appearance record, its migration, or the settings.json schema changes. `preferAbove` selects the anchor, it does not only reorder the candidates. The default measures the above slot from the focus line, which is what the right-side slot needs; measuring above from there would drop the menu onto the earlier selected lines, so that mode measures from the whole selection. Tested: bun run test -- --run src/stores/selectionMenuPlacementStore.test.ts src/hooks/useSelectionPopoverDismiss.test.ts src/components/workspace/surfaces/textSelection.test.ts src/components/chat/MessageList.test.tsx src/pages/settings/ChatAppearanceSettings.test.tsx
1 parent 6d21316 commit 7bab3c2

15 files changed

Lines changed: 407 additions & 3 deletions

‎desktop/src/components/chat/MessageList.test.tsx‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ import { useWorkspaceStore } from '../../stores/workspaceStore'
3333
import { useSettingsStore } from '../../stores/settingsStore'
3434
import { initializeChatAppearance, useChatAppearanceStore } from '../../stores/chatAppearanceStore'
3535
import { CHAT_APPEARANCE_STORAGE_KEY } from '../../lib/chatAppearance'
36+
import {
37+
DEFAULT_SELECTION_MENU_PLACEMENT,
38+
useSelectionMenuPlacementStore,
39+
} from '../../stores/selectionMenuPlacementStore'
3640
import { useSessionStore } from '../../stores/sessionStore'
3741
import { useSideChatStore } from '../../stores/sideChatStore'
3842
import { useTabStore } from '../../stores/tabStore'
@@ -330,6 +334,7 @@ describe('MessageList nested tool calls', () => {
330334
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
331335
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState() } })
332336
useTeamStore.getState().clearTeam()
337+
useSelectionMenuPlacementStore.setState({ placement: DEFAULT_SELECTION_MENU_PLACEMENT })
333338
useWorkspaceChatContextStore.setState(useWorkspaceChatContextStore.getInitialState(), true)
334339
// The workspace panel store is a shared singleton; reset it so preview tabs opened by
335340
// one test (clicking a change-card row) don't dedupe/leak into the next test.
@@ -4104,6 +4109,44 @@ describe('MessageList nested tool calls', () => {
41044109
expect(useWorkspaceChatContextStore.getState().referencesBySession[ACTIVE_TAB]?.[0]?.quote).toContain('finish the selection')
41054110
})
41064111

4112+
it('floats a multi-line selection above the text when the placement preference asks for it', async () => {
4113+
useSelectionMenuPlacementStore.setState({ placement: 'above' })
4114+
useChatStore.setState({
4115+
sessions: {
4116+
[ACTIVE_TAB]: makeSessionState({
4117+
messages: [{
4118+
id: 'assistant-1',
4119+
type: 'assistant_text',
4120+
content: [
4121+
'First line can start the selection.',
4122+
'',
4123+
'Second paragraph should still belong to the same chat message.',
4124+
'',
4125+
'- Third block can finish the selection.',
4126+
].join('\n'),
4127+
timestamp: 1,
4128+
}],
4129+
}),
4130+
},
4131+
})
4132+
4133+
render(<MessageList />)
4134+
4135+
await selectAcrossMessageText(
4136+
screen.getByText('First line can start the selection.'),
4137+
'First line',
4138+
screen.getByText('Third block can finish the selection.'),
4139+
'finish the selection',
4140+
{ left: 160, top: 80, right: 520, bottom: 160, width: 360, height: 80 },
4141+
)
4142+
const floatingAddButton = screen.getByRole('button', { name: 'Add to chat' })
4143+
4144+
// Above the whole selection (top 80 - 44 - 10) and centred on it, instead of
4145+
// over the text that follows the selection.
4146+
expect((floatingAddButton.closest('[role=toolbar]') as HTMLElement).style.left).toBe('160px')
4147+
expect((floatingAddButton.closest('[role=toolbar]') as HTMLElement).style.top).toBe('26px')
4148+
})
4149+
41074150
it('shows the selected-message action after browser selectionchange for multi-line replies', async () => {
41084151
useChatStore.setState({
41094152
sessions: {

‎desktop/src/components/chat/MessageList.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
1313
import { teamTaskWindowsForSnapshot, useTeamStore } from '../../stores/teamStore'
1414
import { useUIStore } from '../../stores/uiStore'
1515
import { useChatAppearanceStore } from '../../stores/chatAppearanceStore'
16+
import { prefersSelectionMenuAbove } from '../../stores/selectionMenuPlacementStore'
1617
import { useTranslation } from '../../i18n'
1718
import type { TranslationKey } from '../../i18n/locales/en'
1819
import { UserMessage } from './UserMessage'
@@ -181,6 +182,9 @@ function getChatSelectionPosition(
181182
offset: CHAT_SELECTION_MENU_OFFSET,
182183
fallbackPointer: pointer,
183184
selectionFocus: { node: selection.focusNode, offset: selection.focusOffset },
185+
// Read from the store: the settled-selection listener that calls this is not
186+
// a render, so there is no hook to subscribe with.
187+
preferAbove: prefersSelectionMenuAbove(),
184188
})
185189
}
186190

‎desktop/src/components/workspace/surfaces/textSelection.test.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import {
66
getSelectionPosition,
77
getTextSelectionFromContainer,
88
} from './textSelection'
9+
import {
10+
DEFAULT_SELECTION_MENU_PLACEMENT,
11+
useSelectionMenuPlacementStore,
12+
} from '@/stores/selectionMenuPlacementStore'
913

1014
function stubRect(target: object, rect: { left: number; top: number; right: number; bottom: number }) {
1115
Object.assign(target, {
@@ -124,6 +128,36 @@ describe('getTextSelectionFromContainer', () => {
124128
beforeEach(() => {
125129
document.body.innerHTML = ''
126130
window.getSelection()?.removeAllRanges()
131+
useSelectionMenuPlacementStore.setState({ placement: DEFAULT_SELECTION_MENU_PLACEMENT })
132+
})
133+
134+
it('places the menu beside a multi-line selection under the upstream rule', () => {
135+
const root = renderCodeRows(['const a = 1', 'const b = 2'])
136+
stubRect(root, { left: 100, top: 24, right: 520, bottom: 420 })
137+
const range = document.createRange()
138+
range.setStart(findTextNode(root.children[0]!), 0)
139+
range.setEnd(findTextNode(root.children[1]!), 11)
140+
// A 40px-tall bounding box is how the library classifies a wrapped range as
141+
// multi-line when the environment reports no per-line rects.
142+
stubRect(range, { left: 120, top: 100, right: 240, bottom: 140 })
143+
window.getSelection()!.addRange(range)
144+
145+
// Beside where the selection ends (240 + 10), centred on its box.
146+
expect(getTextSelectionFromContainer(root)).toMatchObject({ x: 250, y: 104 })
147+
})
148+
149+
it('places the menu above a multi-line selection when the preference asks for it', () => {
150+
const root = renderCodeRows(['const a = 1', 'const b = 2'])
151+
stubRect(root, { left: 100, top: 24, right: 520, bottom: 420 })
152+
const range = document.createRange()
153+
range.setStart(findTextNode(root.children[0]!), 0)
154+
range.setEnd(findTextNode(root.children[1]!), 11)
155+
stubRect(range, { left: 120, top: 100, right: 240, bottom: 140 })
156+
window.getSelection()!.addRange(range)
157+
useSelectionMenuPlacementStore.setState({ placement: 'above' })
158+
159+
// Centred on the whole selection (120..240) and clear of its first line.
160+
expect(getTextSelectionFromContainer(root)).toMatchObject({ x: 110, y: 58 })
127161
})
128162

129163
it('returns the selected text with the line span it covers', () => {

‎desktop/src/components/workspace/surfaces/textSelection.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getSelectionPopoverPosition } from '@/hooks/useSelectionPopoverDismiss'
2+
import { prefersSelectionMenuAbove } from '@/stores/selectionMenuPlacementStore'
23

34
const SELECTION_MENU_OFFSET = 10
45
const SELECTION_MENU_WIDTH = 140
@@ -45,6 +46,7 @@ export function getSelectionPosition(
4546
offset: SELECTION_MENU_OFFSET,
4647
fallbackPointer: pointer,
4748
selectionFocus: { node: selection.focusNode, offset: selection.focusOffset },
49+
preferAbove: prefersSelectionMenuAbove(),
4850
})
4951
}
5052

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { getSelectionPopoverPosition } from './useSelectionPopoverDismiss'
3+
4+
// Workspace surfaces size the menu 140x32 and float it 10px off the selection;
5+
// the chat toolbar uses its own numbers (see components/workspace/surfaces/textSelection.ts
6+
// and components/chat/MessageList.tsx). The placement rule itself is shared.
7+
const MENU = { menuWidth: 140, menuHeight: 32, offset: 10 }
8+
9+
function rect(left: number, top: number, right: number, bottom: number) {
10+
return {
11+
left,
12+
top,
13+
right,
14+
bottom,
15+
width: right - left,
16+
height: bottom - top,
17+
x: left,
18+
y: top,
19+
toJSON: () => ({}),
20+
}
21+
}
22+
23+
function renderLines(lines: string[]) {
24+
const root = document.createElement('div')
25+
for (const line of lines) {
26+
const row = document.createElement('div')
27+
row.textContent = line
28+
root.appendChild(row)
29+
}
30+
document.body.appendChild(root)
31+
return root
32+
}
33+
34+
function textNodeOf(row: Element) {
35+
const node = row.firstChild
36+
if (!node) throw new Error('row has no text node')
37+
return node
38+
}
39+
40+
/**
41+
* A two-line selection whose last line ends at x=200, leaving the whole right
42+
* half of the 1024px viewport free, and whose first line starts at y=100.
43+
*/
44+
function renderTwoLineSelection() {
45+
const root = renderLines(['first selected line', 'second selected line'])
46+
Object.assign(root, { getBoundingClientRect: () => rect(100, 24, 520, 420) })
47+
48+
const first = textNodeOf(root.children[0]!)
49+
const last = textNodeOf(root.children[1]!)
50+
const range = document.createRange()
51+
range.setStart(first, 0)
52+
range.setEnd(last, last.textContent!.length)
53+
Object.assign(range, {
54+
// Two client rects is how the browser reports a selection it wrapped
55+
// across two lines.
56+
getClientRects: () => [rect(120, 100, 500, 118), rect(120, 122, 200, 140)],
57+
getBoundingClientRect: () => rect(120, 100, 500, 140),
58+
})
59+
60+
return { root, range }
61+
}
62+
63+
function positionFor(root: HTMLElement, range: Range, preferAbove?: boolean) {
64+
return getSelectionPopoverPosition(range, root, {
65+
...MENU,
66+
selectionFocus: { node: null, offset: 0 },
67+
...(preferAbove === undefined ? {} : { preferAbove }),
68+
})
69+
}
70+
71+
describe('getSelectionPopoverPosition', () => {
72+
it('places a multi-line selection menu beside the selection by default', () => {
73+
const { root, range } = renderTwoLineSelection()
74+
75+
// The default is the upstream rule: a multi-line range goes to the right of
76+
// where it ends, i.e. the end of the last selected line (200 + 10) centred
77+
// vertically on that line (122 + 9 - 16). Callers opt out per user setting.
78+
expect(positionFor(root, range)).toEqual({ x: 210, y: 115 })
79+
})
80+
81+
it('places a multi-line selection menu above the selection when asked to', () => {
82+
const { root, range } = renderTwoLineSelection()
83+
84+
// The right-side slot sits in the reader's path, so this mode floats the
85+
// menu above the whole selection instead: centred on it (120..500) and
86+
// clear of its first line (top 100 - 32 - 10).
87+
expect(positionFor(root, range, true)).toEqual({ x: 240, y: 58 })
88+
})
89+
90+
it('keeps a single-line selection menu above the selection in either mode', () => {
91+
const root = renderLines(['one line only'])
92+
Object.assign(root, { getBoundingClientRect: () => rect(100, 24, 520, 420) })
93+
94+
const line = textNodeOf(root.children[0]!)
95+
const range = document.createRange()
96+
range.setStart(line, 0)
97+
range.setEnd(line, line.textContent!.length)
98+
Object.assign(range, {
99+
getClientRects: () => [rect(120, 100, 240, 118)],
100+
getBoundingClientRect: () => rect(120, 100, 240, 118),
101+
})
102+
103+
expect(positionFor(root, range)).toEqual({ x: 110, y: 58 })
104+
expect(positionFor(root, range, true)).toEqual({ x: 110, y: 58 })
105+
})
106+
})

‎desktop/src/hooks/useSelectionPopoverDismiss.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,12 +137,19 @@ export function getSelectionPopoverPosition(
137137
offset,
138138
fallbackPointer,
139139
selectionFocus,
140+
preferAbove = false,
140141
}: {
141142
menuWidth: number
142143
menuHeight: number
143144
offset: number
144145
fallbackPointer?: { clientX: number; clientY: number }
145146
selectionFocus?: SelectionFocus
147+
/**
148+
* False keeps the upstream rule: a multi-line selection gets the slot beside
149+
* where it ends. True drops that slot so the menu goes above the selection
150+
* instead. See `selectionMenuPlacement` in lib/chatAppearance.ts.
151+
*/
152+
preferAbove?: boolean
146153
},
147154
) {
148155
const geometry = getRangeSelectionGeometry(range)
@@ -177,16 +184,21 @@ export function getSelectionPopoverPosition(
177184
})
178185
const centerX = anchorRect.left + anchorRect.width / 2
179186
const centerY = anchorRect.top + anchorRect.height / 2
187+
// Preferring above changes where the slot is measured from. The default
188+
// anchors on the focus line, which is what the right-side slot below needs;
189+
// measuring above from there would drop the menu onto the earlier selected
190+
// lines, so that mode measures from the whole selection instead.
191+
const aboveAnchor = preferAbove ? selectionRect : anchorRect
180192
const above = {
181-
x: centerX - menuWidth / 2,
182-
y: anchorRect.top - menuHeight - offset,
193+
x: aboveAnchor.left + aboveAnchor.width / 2 - menuWidth / 2,
194+
y: aboveAnchor.top - menuHeight - offset,
183195
}
184196

185197
const right = {
186198
x: anchorRect.right + offset,
187199
y: centerY - menuHeight / 2,
188200
}
189-
if (geometry?.isMultiLine && right.x + menuWidth <= viewportWidth - VIEWPORT_MARGIN) return clampPosition(right)
201+
if (!preferAbove && geometry?.isMultiLine && right.x + menuWidth <= viewportWidth - VIEWPORT_MARGIN) return clampPosition(right)
190202

191203
if (above.y >= VIEWPORT_MARGIN) return clampPosition(above)
192204

‎desktop/src/i18n/locales/en.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ export const en = {
9595
"settings.chatAppearance.widthWide": "Wide",
9696
"settings.chatAppearance.widthFull": "Fill available space",
9797
"settings.chatAppearance.hint": "Fonts use those available on your system. Code stays monospace. Width applies to messages and the composer; narrow windows fit automatically.",
98+
"settings.chatAppearance.selectionMenuPlacement": "Selection menu",
99+
"settings.chatAppearance.selectionMenuPlacementHint": "The action menu that appears after you select text. Automatic puts it beside a multi-line selection; Above only always floats it over the selection so it never covers the text that follows.",
100+
"settings.chatAppearance.selectionMenuPlacementAuto": "Automatic",
101+
"settings.chatAppearance.selectionMenuPlacementAbove": "Above only",
98102
"settings.chatAppearance.preview": "Font preview",
99103
"settings.chatAppearance.previewMarkdown": "### A clearer conversation\n\nAdjust text for comfortable reading. 中文・日本語・한국어 · Hello 123.\n\n- Lists follow your chosen font and size.\n- Inline code stays monospace: `const answer = 42`.\n\n| Content | Example |\n| --- | --- |\n| Body text | Read at your own pace |",
100104
"chat.sessionNoProject": "No project",

‎desktop/src/i18n/locales/jp.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ export const jp: Record<TranslationKey, string> = {
9696
"settings.chatAppearance.widthWide": "広い",
9797
"settings.chatAppearance.widthFull": "利用可能な幅に合わせる",
9898
"settings.chatAppearance.hint": "システムにあるフォントを使用します。コードは等幅のままです。幅はメッセージと入力欄に適用され、小さいウィンドウでは自動調整されます。",
99+
"settings.chatAppearance.selectionMenuPlacement": "選択メニューの位置",
100+
"settings.chatAppearance.selectionMenuPlacementHint": "テキストを選択すると表示される操作メニューです。「自動」は複数行の選択で右側に表示し、「常に上」は選択範囲の上に表示して、続けて読むテキストを隠しません。",
101+
"settings.chatAppearance.selectionMenuPlacementAuto": "自動",
102+
"settings.chatAppearance.selectionMenuPlacementAbove": "常に上",
99103
"settings.chatAppearance.preview": "フォントのプレビュー",
100104
"settings.chatAppearance.previewMarkdown": "### 読みやすい会話\n\n文字を調整して快適に読みましょう。中文・日本語・한국어 · Hello 123。\n\n- リストにも選択したフォントとサイズが適用されます。\n- インラインコードは等幅のままです:`const answer = 42`。\n\n| 内容 | 例 |\n| --- | --- |\n| 本文 | 自分のペースで読む |",
101105
"chat.sessionNoProject": "プロジェクトなし",

‎desktop/src/i18n/locales/kr.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ export const kr: Record<TranslationKey, string> = {
9696
"settings.chatAppearance.widthWide": "넓게",
9797
"settings.chatAppearance.widthFull": "사용 가능한 너비 채우기",
9898
"settings.chatAppearance.hint": "시스템에 있는 글꼴을 사용합니다. 코드는 고정폭을 유지합니다. 너비는 메시지와 입력창에 적용되며 작은 창에서는 자동 조정됩니다.",
99+
"settings.chatAppearance.selectionMenuPlacement": "선택 메뉴 위치",
100+
"settings.chatAppearance.selectionMenuPlacementHint": "텍스트를 선택하면 나타나는 작업 메뉴입니다. 자동은 여러 줄 선택 시 오른쪽에 표시하고, 항상 위는 선택 영역 위에 표시해 이어서 읽을 텍스트를 가리지 않습니다.",
101+
"settings.chatAppearance.selectionMenuPlacementAuto": "자동",
102+
"settings.chatAppearance.selectionMenuPlacementAbove": "항상 위",
99103
"settings.chatAppearance.preview": "글꼴 미리보기",
100104
"settings.chatAppearance.previewMarkdown": "### 편안하게 읽는 대화\n\n글자를 조정하여 편안하게 읽으세요. 中文・日本語・한국어 · Hello 123.\n\n- 목록에도 선택한 글꼴과 크기가 적용됩니다.\n- 인라인 코드는 고정폭을 유지합니다: `const answer = 42`.\n\n| 내용 | 예시 |\n| --- | --- |\n| 본문 | 자신의 속도로 읽기 |",
101105

‎desktop/src/i18n/locales/zh-TW.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ export const zh: Record<TranslationKey, string> = {
9696
"settings.chatAppearance.widthWide": "寬",
9797
"settings.chatAppearance.widthFull": "填滿可用區域",
9898
"settings.chatAppearance.hint": "字型使用系統已有字型,程式碼保持等寬。寬度同時套用於訊息和輸入框,小視窗會自動適配。",
99+
"settings.chatAppearance.selectionMenuPlacement": "選取氣泡位置",
100+
"settings.chatAppearance.selectionMenuPlacementHint": "選取文字後彈出的操作氣泡。「自動」在多行選取時放在選取區右側;「僅在上方」一律放在選取區上方,不會擋住後面要讀的文字。",
101+
"settings.chatAppearance.selectionMenuPlacementAuto": "自動",
102+
"settings.chatAppearance.selectionMenuPlacementAbove": "僅在上方",
99103
"settings.chatAppearance.preview": "字型預覽",
100104
"settings.chatAppearance.previewMarkdown": "### 更舒適的對話\n\n調整文字,讓閱讀更舒適。中文・日本語・한국어 · Hello 123。\n\n- 清單跟隨所選字型與字級。\n- 行內程式碼保持等寬:`const answer = 42`。\n\n| 內容 | 範例 |\n| --- | --- |\n| 本文 | 按自己的步調閱讀 |",
101105
"chat.sessionNoProject": "無專案",

0 commit comments

Comments
 (0)