mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-24 14:43:35 +08:00
Merge commit '2b5bf56475c5a8afd2ccd40df4e63e58d4d88c87'
This commit is contained in:
commit
0506452a1b
@ -905,8 +905,8 @@ describe('MessageList nested tool calls', () => {
|
|||||||
await selectMessageText(userText, 'workspace selection behavior')
|
await selectMessageText(userText, 'workspace selection behavior')
|
||||||
const floatingAddButton = screen.getByRole('button', { name: 'Add to chat' })
|
const floatingAddButton = screen.getByRole('button', { name: 'Add to chat' })
|
||||||
|
|
||||||
expect(floatingAddButton.style.left).toBe('260px')
|
expect(floatingAddButton.style.left).toBe('141px')
|
||||||
expect(floatingAddButton.style.top).toBe('112px')
|
expect(floatingAddButton.style.top).toBe('26px')
|
||||||
|
|
||||||
fireEvent.click(floatingAddButton)
|
fireEvent.click(floatingAddButton)
|
||||||
|
|
||||||
@ -955,6 +955,80 @@ describe('MessageList nested tool calls', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('dismisses the selected-message action when clicking outside the popover', async () => {
|
||||||
|
useChatStore.setState({
|
||||||
|
sessions: {
|
||||||
|
[ACTIVE_TAB]: makeSessionState({
|
||||||
|
messages: [{
|
||||||
|
id: 'assistant-1',
|
||||||
|
type: 'assistant_text',
|
||||||
|
content: 'Clicking outside should clear this selected reply.',
|
||||||
|
timestamp: 1,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<MessageList />)
|
||||||
|
|
||||||
|
const assistantText = screen.getByText(/Clicking outside should clear/)
|
||||||
|
await selectMessageText(assistantText, 'selected reply')
|
||||||
|
expect(screen.getByRole('button', { name: 'Add to chat' })).toBeTruthy()
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.pointerDown(document.body)
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.queryByRole('button', { name: 'Add to chat' })).toBeNull()
|
||||||
|
expect(window.getSelection()?.toString()).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps only the latest selected-message action when selecting across messages', async () => {
|
||||||
|
useChatStore.setState({
|
||||||
|
sessions: {
|
||||||
|
[ACTIVE_TAB]: makeSessionState({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'assistant-1',
|
||||||
|
type: 'assistant_text',
|
||||||
|
content: 'First assistant reply can be selected.',
|
||||||
|
timestamp: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'assistant-2',
|
||||||
|
type: 'assistant_text',
|
||||||
|
content: 'Second assistant reply should replace it.',
|
||||||
|
timestamp: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
render(<MessageList />)
|
||||||
|
|
||||||
|
const firstText = screen.getByText(/First assistant reply/)
|
||||||
|
const secondText = screen.getByText(/Second assistant reply/)
|
||||||
|
await selectMessageText(firstText, 'First assistant reply')
|
||||||
|
expect(screen.getAllByRole('button', { name: 'Add to chat' })).toHaveLength(1)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.pointerDown(secondText)
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
await selectMessageText(secondText, 'Second assistant reply')
|
||||||
|
|
||||||
|
expect(screen.getAllByRole('button', { name: 'Add to chat' })).toHaveLength(1)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add to chat' }))
|
||||||
|
expect(useWorkspaceChatContextStore.getState().referencesBySession[ACTIVE_TAB]).toMatchObject([
|
||||||
|
{
|
||||||
|
messageId: 'assistant-2',
|
||||||
|
quote: 'Second assistant reply',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it('does not force-scroll to the bottom while the user is reading history', async () => {
|
it('does not force-scroll to the bottom while the user is reading history', async () => {
|
||||||
const scrollIntoView = vi.fn()
|
const scrollIntoView = vi.fn()
|
||||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect, type ReactNode } from 'react'
|
import { useRef, useEffect, useMemo, memo, useState, useCallback, useLayoutEffect, type ReactNode } from 'react'
|
||||||
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, LoaderCircle, Settings, Target, XCircle } from 'lucide-react'
|
import { ArrowDown, BookMarked, Bot, CheckCircle2, ChevronDown, ChevronRight, CircleStop, LoaderCircle, MessageCircle, Settings, Target, XCircle } from 'lucide-react'
|
||||||
import { ApiError } from '../../api/client'
|
import { ApiError } from '../../api/client'
|
||||||
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
|
import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
|
||||||
import { useChatStore } from '../../stores/chatStore'
|
import { useChatStore } from '../../stores/chatStore'
|
||||||
@ -22,6 +22,7 @@ import { InlineTaskSummary } from './InlineTaskSummary'
|
|||||||
import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
|
import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
|
||||||
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
|
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
|
||||||
import { ConfirmDialog } from '../shared/ConfirmDialog'
|
import { ConfirmDialog } from '../shared/ConfirmDialog'
|
||||||
|
import { clearWindowSelection, getSelectionPopoverPosition, useSelectionPopoverDismiss } from '../../hooks/useSelectionPopoverDismiss'
|
||||||
|
|
||||||
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
|
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
|
||||||
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
|
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
|
||||||
@ -62,11 +63,9 @@ type ChatSelectionState = {
|
|||||||
y: number
|
y: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHAT_SELECTION_MENU_OFFSET = 8
|
const CHAT_SELECTION_MENU_OFFSET = 10
|
||||||
|
const CHAT_SELECTION_MENU_WIDTH = 158
|
||||||
function clampValue(value: number, min: number, max: number) {
|
const CHAT_SELECTION_MENU_HEIGHT = 44
|
||||||
return Math.max(min, Math.min(value, max))
|
|
||||||
}
|
|
||||||
|
|
||||||
function getElementForNode(node: Node | null): Element | null {
|
function getElementForNode(node: Node | null): Element | null {
|
||||||
if (!node) return null
|
if (!node) return null
|
||||||
@ -74,32 +73,12 @@ function getElementForNode(node: Node | null): Element | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getChatSelectionPosition(range: Range, root: HTMLElement, pointer: { clientX: number; clientY: number }) {
|
function getChatSelectionPosition(range: Range, root: HTMLElement, pointer: { clientX: number; clientY: number }) {
|
||||||
const rect = typeof range.getBoundingClientRect === 'function'
|
return getSelectionPopoverPosition(range, root, {
|
||||||
? range.getBoundingClientRect()
|
menuWidth: CHAT_SELECTION_MENU_WIDTH,
|
||||||
: null
|
menuHeight: CHAT_SELECTION_MENU_HEIGHT,
|
||||||
const rootRect = root.getBoundingClientRect()
|
offset: CHAT_SELECTION_MENU_OFFSET,
|
||||||
const pointerInsideRoot =
|
fallbackPointer: pointer,
|
||||||
pointer.clientX >= rootRect.left &&
|
})
|
||||||
pointer.clientX <= rootRect.right &&
|
|
||||||
pointer.clientY >= rootRect.top &&
|
|
||||||
pointer.clientY <= rootRect.bottom
|
|
||||||
const fallbackX = rect && rect.width > 0
|
|
||||||
? rect.left + rect.width / 2
|
|
||||||
: rect?.left ?? rootRect.left + 24
|
|
||||||
const fallbackY = rect
|
|
||||||
? rect.bottom + CHAT_SELECTION_MENU_OFFSET
|
|
||||||
: rootRect.top + 24
|
|
||||||
const unclampedX = pointerInsideRoot ? pointer.clientX : fallbackX
|
|
||||||
const unclampedY = pointerInsideRoot ? pointer.clientY + CHAT_SELECTION_MENU_OFFSET : fallbackY
|
|
||||||
const minX = Math.max(12, rootRect.left + 8)
|
|
||||||
const maxX = Math.max(minX, Math.min(window.innerWidth - 160, rootRect.right - 136))
|
|
||||||
const minY = Math.max(12, rootRect.top + 8)
|
|
||||||
const maxY = Math.max(minY, Math.min(window.innerHeight - 48, rootRect.bottom - 40))
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: clampValue(unclampedX, minX, maxX),
|
|
||||||
y: clampValue(unclampedY, minY, maxY),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getChatSelectionFromContainer(
|
function getChatSelectionFromContainer(
|
||||||
@ -129,22 +108,25 @@ function getChatSelectionFromContainer(
|
|||||||
function ChatSelectionMenu({
|
function ChatSelectionMenu({
|
||||||
selection,
|
selection,
|
||||||
onAdd,
|
onAdd,
|
||||||
|
popoverRef,
|
||||||
}: {
|
}: {
|
||||||
selection: ChatSelectionState | null
|
selection: ChatSelectionState | null
|
||||||
onAdd: () => void
|
onAdd: () => void
|
||||||
|
popoverRef: { current: HTMLButtonElement | null }
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
if (!selection) return null
|
if (!selection) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
ref={popoverRef}
|
||||||
type="button"
|
type="button"
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
onClick={onAdd}
|
onClick={onAdd}
|
||||||
className="fixed z-50 inline-flex h-8 items-center gap-1.5 rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5 text-[12px] font-medium text-[var(--color-text-primary)] shadow-[var(--shadow-dropdown)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
className="fixed z-50 inline-flex h-11 items-center gap-2 rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-lowest)] px-5 text-[15px] font-semibold text-[var(--color-text-primary)] shadow-[0_10px_28px_rgba(15,23,42,0.14),0_2px_8px_rgba(15,23,42,0.08)] transition-colors hover:bg-[var(--color-surface)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||||
style={{ left: selection.x, top: selection.y }}
|
style={{ left: selection.x, top: selection.y }}
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" className="material-symbols-outlined text-[15px] text-[var(--color-text-tertiary)]">person_add</span>
|
<MessageCircle size={21} strokeWidth={2.15} className="shrink-0 text-[var(--color-text-primary)]" aria-hidden="true" />
|
||||||
<span>{t('chat.addSelectionToChat')}</span>
|
<span>{t('chat.addSelectionToChat')}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@ -299,6 +281,7 @@ function SelectableChatMessage({
|
|||||||
children: ReactNode
|
children: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const rootRef = useRef<HTMLDivElement>(null)
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
const selectionMenuRef = useRef<HTMLButtonElement>(null)
|
||||||
const addReference = useWorkspaceChatContextStore((state) => state.addReference)
|
const addReference = useWorkspaceChatContextStore((state) => state.addReference)
|
||||||
const [selectionMenu, setSelectionMenu] = useState<ChatSelectionState | null>(null)
|
const [selectionMenu, setSelectionMenu] = useState<ChatSelectionState | null>(null)
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
@ -310,6 +293,16 @@ function SelectableChatMessage({
|
|||||||
setSelectionMenu(null)
|
setSelectionMenu(null)
|
||||||
}, [content, messageId])
|
}, [content, messageId])
|
||||||
|
|
||||||
|
const dismissSelectionMenu = useCallback(() => {
|
||||||
|
setSelectionMenu(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useSelectionPopoverDismiss({
|
||||||
|
active: Boolean(selectionMenu),
|
||||||
|
popoverRef: selectionMenuRef,
|
||||||
|
onDismiss: dismissSelectionMenu,
|
||||||
|
})
|
||||||
|
|
||||||
const addCurrentSelectionToChat = useCallback(() => {
|
const addCurrentSelectionToChat = useCallback(() => {
|
||||||
if (!sessionId || !selectionMenu) return
|
if (!sessionId || !selectionMenu) return
|
||||||
addReference(sessionId, {
|
addReference(sessionId, {
|
||||||
@ -321,7 +314,7 @@ function SelectableChatMessage({
|
|||||||
messageId,
|
messageId,
|
||||||
})
|
})
|
||||||
setSelectionMenu(null)
|
setSelectionMenu(null)
|
||||||
window.getSelection()?.removeAllRanges()
|
clearWindowSelection()
|
||||||
}, [addReference, messageId, role, selectionMenu, sessionId, sourceName])
|
}, [addReference, messageId, role, selectionMenu, sessionId, sourceName])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -335,7 +328,7 @@ function SelectableChatMessage({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChatSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} />
|
<ChatSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} popoverRef={selectionMenuRef} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -144,13 +144,13 @@ async function selectWorkspaceCodeText(
|
|||||||
Object.assign(range, {
|
Object.assign(range, {
|
||||||
getBoundingClientRect: () => ({
|
getBoundingClientRect: () => ({
|
||||||
left: 120,
|
left: 120,
|
||||||
top: 40,
|
top: 100,
|
||||||
right: 240,
|
right: 240,
|
||||||
bottom: 58,
|
bottom: 118,
|
||||||
width: 120,
|
width: 120,
|
||||||
height: 18,
|
height: 18,
|
||||||
x: 120,
|
x: 120,
|
||||||
y: 40,
|
y: 100,
|
||||||
toJSON: () => ({}),
|
toJSON: () => ({}),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@ -160,7 +160,7 @@ async function selectWorkspaceCodeText(
|
|||||||
selection?.addRange(range)
|
selection?.addRange(range)
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.mouseUp(code, { clientX: 180, clientY: 72 })
|
fireEvent.mouseUp(code, { clientX: 180, clientY: 122 })
|
||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
})
|
})
|
||||||
await flushReactWork()
|
await flushReactWork()
|
||||||
@ -1444,8 +1444,8 @@ describe('WorkspacePanel', () => {
|
|||||||
const addButtons = view.getAllByRole('button', { name: 'Add to chat' })
|
const addButtons = view.getAllByRole('button', { name: 'Add to chat' })
|
||||||
const floatingAddButton = addButtons[addButtons.length - 1]!
|
const floatingAddButton = addButtons[addButtons.length - 1]!
|
||||||
|
|
||||||
expect(floatingAddButton.style.left).toBe('180px')
|
expect(floatingAddButton.style.left).toBe('101px')
|
||||||
expect(floatingAddButton.style.top).toBe('80px')
|
expect(floatingAddButton.style.top).toBe('46px')
|
||||||
|
|
||||||
fireEvent.keyDown(view.getByTestId('workspace-code').parentElement?.parentElement ?? view.getByTestId('workspace-code'), {
|
fireEvent.keyDown(view.getByTestId('workspace-code').parentElement?.parentElement ?? view.getByTestId('workspace-code'), {
|
||||||
key: 'Escape',
|
key: 'Escape',
|
||||||
@ -1454,6 +1454,61 @@ describe('WorkspacePanel', () => {
|
|||||||
expect(view.queryAllByRole('button', { name: 'Add to chat' })).toHaveLength(1)
|
expect(view.queryAllByRole('button', { name: 'Add to chat' })).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('dismisses the selected-code action when clicking outside the popover', async () => {
|
||||||
|
await setWorkspaceState((state) => ({
|
||||||
|
...state,
|
||||||
|
panelBySession: {
|
||||||
|
...state.panelBySession,
|
||||||
|
'session-selection-dismiss': {
|
||||||
|
isOpen: true,
|
||||||
|
activeView: 'all',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
statusBySession: {
|
||||||
|
...state.statusBySession,
|
||||||
|
'session-selection-dismiss': {
|
||||||
|
state: 'ok',
|
||||||
|
workDir: '/repo',
|
||||||
|
repoName: 'repo',
|
||||||
|
branch: 'main',
|
||||||
|
isGitRepo: true,
|
||||||
|
changedFiles: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
previewTabsBySession: {
|
||||||
|
...state.previewTabsBySession,
|
||||||
|
'session-selection-dismiss': [{
|
||||||
|
id: 'file:src/App.ts',
|
||||||
|
path: 'src/App.ts',
|
||||||
|
kind: 'file',
|
||||||
|
title: 'App.ts',
|
||||||
|
language: 'text',
|
||||||
|
content: 'const title = "Todo"\nexport default title',
|
||||||
|
state: 'ok',
|
||||||
|
size: 42,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
activePreviewTabIdBySession: {
|
||||||
|
...state.activePreviewTabIdBySession,
|
||||||
|
'session-selection-dismiss': 'file:src/App.ts',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const view = await renderPanel('session-selection-dismiss')
|
||||||
|
|
||||||
|
await selectWorkspaceCodeText(view, 1, 'const title = "Todo"', 1, 'const title = "Todo"')
|
||||||
|
expect(view.getAllByRole('button', { name: 'Add to chat' })).toHaveLength(2)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.pointerDown(document.body)
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
await flushReactWork()
|
||||||
|
|
||||||
|
expect(view.queryAllByRole('button', { name: 'Add to chat' })).toHaveLength(1)
|
||||||
|
expect(window.getSelection()?.toString()).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
it('adds selected markdown text from a preview to the chat context', async () => {
|
it('adds selected markdown text from a preview to the chat context', async () => {
|
||||||
await setWorkspaceState((state) => ({
|
await setWorkspaceState((state) => ({
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'
|
||||||
|
import { MessageCircle } from 'lucide-react'
|
||||||
import { Highlight } from 'prism-react-renderer'
|
import { Highlight } from 'prism-react-renderer'
|
||||||
import type {
|
import type {
|
||||||
WorkspaceChangedFile,
|
WorkspaceChangedFile,
|
||||||
@ -18,6 +19,7 @@ import { useChatStore } from '../../stores/chatStore'
|
|||||||
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
|
||||||
import { useUIStore } from '../../stores/uiStore'
|
import { useUIStore } from '../../stores/uiStore'
|
||||||
import { copyTextToClipboard } from '../chat/clipboard'
|
import { copyTextToClipboard } from '../chat/clipboard'
|
||||||
|
import { clearWindowSelection, getSelectionPopoverPosition, useSelectionPopoverDismiss } from '../../hooks/useSelectionPopoverDismiss'
|
||||||
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||||
import {
|
import {
|
||||||
getFileExtension,
|
getFileExtension,
|
||||||
@ -84,7 +86,9 @@ const FILE_STATUS_META: Record<WorkspaceFileStatus, { label: string; className:
|
|||||||
const EMPTY_TREE_BY_PATH: Record<string, WorkspaceTreeResult | undefined> = {}
|
const EMPTY_TREE_BY_PATH: Record<string, WorkspaceTreeResult | undefined> = {}
|
||||||
const EMPTY_PREVIEW_TABS: WorkspacePreviewTab[] = []
|
const EMPTY_PREVIEW_TABS: WorkspacePreviewTab[] = []
|
||||||
const EMPTY_EXPANDED_PATHS: string[] = []
|
const EMPTY_EXPANDED_PATHS: string[] = []
|
||||||
const SELECTION_MENU_OFFSET = 8
|
const SELECTION_MENU_OFFSET = 10
|
||||||
|
const SELECTION_MENU_WIDTH = 158
|
||||||
|
const SELECTION_MENU_HEIGHT = 44
|
||||||
const FILE_BADGE_META: Record<string, { label: string; className: string }> = {
|
const FILE_BADGE_META: Record<string, { label: string; className: string }> = {
|
||||||
ts: { label: 'TS', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' },
|
ts: { label: 'TS', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' },
|
||||||
tsx: { label: 'TSX', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' },
|
tsx: { label: 'TSX', className: 'bg-[var(--color-secondary)]/14 text-[var(--color-secondary)]' },
|
||||||
@ -239,37 +243,13 @@ function getLineNumberFromNode(node: Node | null, root: HTMLElement) {
|
|||||||
return Number.isFinite(line) ? line : undefined
|
return Number.isFinite(line) ? line : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampValue(value: number, min: number, max: number) {
|
|
||||||
return Math.max(min, Math.min(value, max))
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSelectionPosition(range: Range, root: HTMLElement, pointer?: SelectionPointer) {
|
function getSelectionPosition(range: Range, root: HTMLElement, pointer?: SelectionPointer) {
|
||||||
const rect = typeof range.getBoundingClientRect === 'function'
|
return getSelectionPopoverPosition(range, root, {
|
||||||
? range.getBoundingClientRect()
|
menuWidth: SELECTION_MENU_WIDTH,
|
||||||
: null
|
menuHeight: SELECTION_MENU_HEIGHT,
|
||||||
const rootRect = root.getBoundingClientRect()
|
offset: SELECTION_MENU_OFFSET,
|
||||||
const pointerInsideRoot = pointer
|
fallbackPointer: pointer,
|
||||||
&& pointer.clientX >= rootRect.left
|
})
|
||||||
&& pointer.clientX <= rootRect.right
|
|
||||||
&& pointer.clientY >= rootRect.top
|
|
||||||
&& pointer.clientY <= rootRect.bottom
|
|
||||||
const fallbackX = rect && rect.width > 0
|
|
||||||
? rect.left + rect.width / 2
|
|
||||||
: rect?.left ?? rootRect.left + 24
|
|
||||||
const fallbackY = rect
|
|
||||||
? rect.bottom + SELECTION_MENU_OFFSET
|
|
||||||
: rootRect.top + 24
|
|
||||||
const unclampedX = pointerInsideRoot ? pointer.clientX : fallbackX
|
|
||||||
const unclampedY = pointerInsideRoot ? pointer.clientY + SELECTION_MENU_OFFSET : fallbackY
|
|
||||||
const minX = Math.max(12, rootRect.left + 8)
|
|
||||||
const maxX = Math.max(minX, Math.min(window.innerWidth - 160, rootRect.right - 136))
|
|
||||||
const minY = Math.max(12, rootRect.top + 8)
|
|
||||||
const maxY = Math.max(minY, Math.min(window.innerHeight - 48, rootRect.bottom - 40))
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: clampValue(unclampedX, minX, maxX),
|
|
||||||
y: clampValue(unclampedY, minY, maxY),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTextSelectionFromContainer(
|
function getTextSelectionFromContainer(
|
||||||
@ -321,22 +301,25 @@ function getLineRangeForText(value: string, text: string) {
|
|||||||
function FloatingSelectionMenu({
|
function FloatingSelectionMenu({
|
||||||
selection,
|
selection,
|
||||||
onAdd,
|
onAdd,
|
||||||
|
popoverRef,
|
||||||
}: {
|
}: {
|
||||||
selection: FloatingSelectionMenuState | null
|
selection: FloatingSelectionMenuState | null
|
||||||
onAdd: () => void
|
onAdd: () => void
|
||||||
|
popoverRef: { current: HTMLButtonElement | null }
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
if (!selection) return null
|
if (!selection) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
ref={popoverRef}
|
||||||
type="button"
|
type="button"
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
onClick={onAdd}
|
onClick={onAdd}
|
||||||
className="fixed z-50 inline-flex h-8 items-center gap-1.5 rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5 text-[12px] font-medium text-[var(--color-text-primary)] shadow-[var(--shadow-dropdown)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
className="fixed z-50 inline-flex h-11 items-center gap-2 rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-lowest)] px-5 text-[15px] font-semibold text-[var(--color-text-primary)] shadow-[0_10px_28px_rgba(15,23,42,0.14),0_2px_8px_rgba(15,23,42,0.08)] transition-colors hover:bg-[var(--color-surface)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
|
||||||
style={{ left: selection.x, top: selection.y }}
|
style={{ left: selection.x, top: selection.y }}
|
||||||
>
|
>
|
||||||
<span aria-hidden="true" className="material-symbols-outlined text-[15px] text-[var(--color-text-tertiary)]">person_add</span>
|
<MessageCircle size={21} strokeWidth={2.15} className="shrink-0 text-[var(--color-text-primary)]" aria-hidden="true" />
|
||||||
<span>{t('workspace.addSelectionToChat')}</span>
|
<span>{t('workspace.addSelectionToChat')}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@ -452,6 +435,7 @@ function CodeSurface({
|
|||||||
}) {
|
}) {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const surfaceRef = useRef<HTMLDivElement>(null)
|
const surfaceRef = useRef<HTMLDivElement>(null)
|
||||||
|
const selectionMenuRef = useRef<HTMLButtonElement>(null)
|
||||||
const [commentLine, setCommentLine] = useState<number | null>(null)
|
const [commentLine, setCommentLine] = useState<number | null>(null)
|
||||||
const [commentDraft, setCommentDraft] = useState('')
|
const [commentDraft, setCommentDraft] = useState('')
|
||||||
const [showAllLines, setShowAllLines] = useState(false)
|
const [showAllLines, setShowAllLines] = useState(false)
|
||||||
@ -469,6 +453,16 @@ function CodeSurface({
|
|||||||
setSelectionMenu(null)
|
setSelectionMenu(null)
|
||||||
}, [language, value])
|
}, [language, value])
|
||||||
|
|
||||||
|
const dismissSelectionMenu = useCallback(() => {
|
||||||
|
setSelectionMenu(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useSelectionPopoverDismiss({
|
||||||
|
active: Boolean(selectionMenu),
|
||||||
|
popoverRef: selectionMenuRef,
|
||||||
|
onDismiss: dismissSelectionMenu,
|
||||||
|
})
|
||||||
|
|
||||||
const submitLineComment = () => {
|
const submitLineComment = () => {
|
||||||
if (!commentLine || !commentDraft.trim()) return
|
if (!commentLine || !commentDraft.trim()) return
|
||||||
onAddLineComment(commentLine, commentDraft.trim(), activeQuote)
|
onAddLineComment(commentLine, commentDraft.trim(), activeQuote)
|
||||||
@ -497,7 +491,7 @@ function CodeSurface({
|
|||||||
endLine: selectionMenu.endLine,
|
endLine: selectionMenu.endLine,
|
||||||
})
|
})
|
||||||
setSelectionMenu(null)
|
setSelectionMenu(null)
|
||||||
window.getSelection()?.removeAllRanges()
|
clearWindowSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderLineCommentEditor = (lineNumber: number) => {
|
const renderLineCommentEditor = (lineNumber: number) => {
|
||||||
@ -650,7 +644,7 @@ function CodeSurface({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} />
|
<FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} popoverRef={selectionMenuRef} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -663,12 +657,23 @@ function MarkdownSurface({
|
|||||||
onAddSelection: (selection: WorkspaceTextSelection) => void
|
onAddSelection: (selection: WorkspaceTextSelection) => void
|
||||||
}) {
|
}) {
|
||||||
const surfaceRef = useRef<HTMLDivElement>(null)
|
const surfaceRef = useRef<HTMLDivElement>(null)
|
||||||
|
const selectionMenuRef = useRef<HTMLButtonElement>(null)
|
||||||
const [selectionMenu, setSelectionMenu] = useState<FloatingSelectionMenuState | null>(null)
|
const [selectionMenu, setSelectionMenu] = useState<FloatingSelectionMenuState | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectionMenu(null)
|
setSelectionMenu(null)
|
||||||
}, [value])
|
}, [value])
|
||||||
|
|
||||||
|
const dismissSelectionMenu = useCallback(() => {
|
||||||
|
setSelectionMenu(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useSelectionPopoverDismiss({
|
||||||
|
active: Boolean(selectionMenu),
|
||||||
|
popoverRef: selectionMenuRef,
|
||||||
|
onDismiss: dismissSelectionMenu,
|
||||||
|
})
|
||||||
|
|
||||||
const handleSelectionMouseUp = (event: MouseEvent<HTMLDivElement>) => {
|
const handleSelectionMouseUp = (event: MouseEvent<HTMLDivElement>) => {
|
||||||
setSelectionMenu(getTextSelectionFromContainer(
|
setSelectionMenu(getTextSelectionFromContainer(
|
||||||
surfaceRef.current,
|
surfaceRef.current,
|
||||||
@ -685,7 +690,7 @@ function MarkdownSurface({
|
|||||||
endLine: selectionMenu.endLine,
|
endLine: selectionMenu.endLine,
|
||||||
})
|
})
|
||||||
setSelectionMenu(null)
|
setSelectionMenu(null)
|
||||||
window.getSelection()?.removeAllRanges()
|
clearWindowSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -704,7 +709,7 @@ function MarkdownSurface({
|
|||||||
className="workspace-markdown-preview prose-p:text-[14px] prose-p:leading-7 prose-h1:text-[24px] prose-h2:text-[18px] prose-h3:text-[15px] prose-code:text-[12px] prose-pre:my-4"
|
className="workspace-markdown-preview prose-p:text-[14px] prose-p:leading-7 prose-h1:text-[24px] prose-h2:text-[18px] prose-h3:text-[15px] prose-code:text-[12px] prose-pre:my-4"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} />
|
<FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} popoverRef={selectionMenuRef} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
93
desktop/src/hooks/useSelectionPopoverDismiss.ts
Normal file
93
desktop/src/hooks/useSelectionPopoverDismiss.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
type ElementRef = {
|
||||||
|
current: HTMLElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEWPORT_MARGIN = 12
|
||||||
|
|
||||||
|
function clampValue(value: number, min: number, max: number) {
|
||||||
|
return Math.max(min, Math.min(value, max))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearWindowSelection() {
|
||||||
|
window.getSelection()?.removeAllRanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSelectionPopoverPosition(
|
||||||
|
range: Range,
|
||||||
|
root: HTMLElement,
|
||||||
|
{
|
||||||
|
menuWidth,
|
||||||
|
menuHeight,
|
||||||
|
offset,
|
||||||
|
fallbackPointer,
|
||||||
|
}: {
|
||||||
|
menuWidth: number
|
||||||
|
menuHeight: number
|
||||||
|
offset: number
|
||||||
|
fallbackPointer?: { clientX: number; clientY: number }
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const rect = typeof range.getBoundingClientRect === 'function'
|
||||||
|
? range.getBoundingClientRect()
|
||||||
|
: null
|
||||||
|
const rootRect = root.getBoundingClientRect()
|
||||||
|
const hasUsableRangeRect = Boolean(rect && (rect.width > 0 || rect.height > 0))
|
||||||
|
const pointerInsideRoot = fallbackPointer
|
||||||
|
&& fallbackPointer.clientX >= rootRect.left
|
||||||
|
&& fallbackPointer.clientX <= rootRect.right
|
||||||
|
&& fallbackPointer.clientY >= rootRect.top
|
||||||
|
&& fallbackPointer.clientY <= rootRect.bottom
|
||||||
|
const fallbackLeft = pointerInsideRoot ? fallbackPointer.clientX - menuWidth / 2 : rootRect.left + 24
|
||||||
|
const fallbackTop = pointerInsideRoot ? fallbackPointer.clientY : rootRect.top + 24
|
||||||
|
const selectionLeft = hasUsableRangeRect ? rect!.left : fallbackLeft
|
||||||
|
const selectionRight = hasUsableRangeRect ? rect!.right : selectionLeft + menuWidth
|
||||||
|
const selectionTop = hasUsableRangeRect ? rect!.top : fallbackTop
|
||||||
|
const selectionBottom = hasUsableRangeRect ? rect!.bottom : selectionTop + menuHeight
|
||||||
|
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || rootRect.right + VIEWPORT_MARGIN
|
||||||
|
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || rootRect.bottom + VIEWPORT_MARGIN
|
||||||
|
const minX = VIEWPORT_MARGIN
|
||||||
|
const maxX = Math.max(minX, viewportWidth - menuWidth - VIEWPORT_MARGIN)
|
||||||
|
const minY = VIEWPORT_MARGIN
|
||||||
|
const maxY = Math.max(minY, viewportHeight - menuHeight - VIEWPORT_MARGIN)
|
||||||
|
const aboveY = selectionTop - menuHeight - offset
|
||||||
|
const belowY = selectionBottom + offset
|
||||||
|
const y = aboveY >= VIEWPORT_MARGIN || belowY + menuHeight > viewportHeight - VIEWPORT_MARGIN
|
||||||
|
? aboveY
|
||||||
|
: belowY
|
||||||
|
const centerX = selectionLeft + (selectionRight - selectionLeft) / 2
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: clampValue(centerX - menuWidth / 2, minX, maxX),
|
||||||
|
y: clampValue(y, minY, maxY),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSelectionPopoverDismiss({
|
||||||
|
active,
|
||||||
|
popoverRef,
|
||||||
|
onDismiss,
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
popoverRef: ElementRef
|
||||||
|
onDismiss: () => void
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return
|
||||||
|
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
const popover = popoverRef.current
|
||||||
|
const target = event.target
|
||||||
|
if (popover && target instanceof Node && popover.contains(target)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onDismiss()
|
||||||
|
clearWindowSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('pointerdown', handlePointerDown, true)
|
||||||
|
return () => document.removeEventListener('pointerdown', handlePointerDown, true)
|
||||||
|
}, [active, onDismiss, popoverRef])
|
||||||
|
}
|
||||||
@ -122,7 +122,7 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'workspace.showAllLoadedLines': '显示全部已加载行',
|
'workspace.showAllLoadedLines': '显示全部已加载行',
|
||||||
'workspace.collapsePreview': '收起预览',
|
'workspace.collapsePreview': '收起预览',
|
||||||
'workspace.addToChat': '添加到聊天',
|
'workspace.addToChat': '添加到聊天',
|
||||||
'workspace.addSelectionToChat': '添加至对话',
|
'workspace.addSelectionToChat': '添加到对话',
|
||||||
'workspace.copyPath': '复制路径',
|
'workspace.copyPath': '复制路径',
|
||||||
'workspace.pathCopied': '路径已复制。',
|
'workspace.pathCopied': '路径已复制。',
|
||||||
'workspace.localComment': '本地评论',
|
'workspace.localComment': '本地评论',
|
||||||
@ -907,7 +907,7 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'chat.dropFilesHint': '会以文件路径附加到当前输入。',
|
'chat.dropFilesHint': '会以文件路径附加到当前输入。',
|
||||||
'chat.workspaceReferencesOnly': '已添加 {count} 个工作区引用',
|
'chat.workspaceReferencesOnly': '已添加 {count} 个工作区引用',
|
||||||
'chat.contextReferencesOnly': '已添加 {count} 个引用',
|
'chat.contextReferencesOnly': '已添加 {count} 个引用',
|
||||||
'chat.addSelectionToChat': '添加至对话',
|
'chat.addSelectionToChat': '添加到对话',
|
||||||
'chat.userMessageReference': '用户消息',
|
'chat.userMessageReference': '用户消息',
|
||||||
'chat.assistantMessageReference': 'AI 回复',
|
'chat.assistantMessageReference': 'AI 回复',
|
||||||
'chat.slashCommands': '斜杠命令',
|
'chat.slashCommands': '斜杠命令',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user