Merge commit '2b5bf56475c5a8afd2ccd40df4e63e58d4d88c87'

This commit is contained in:
程序员阿江(Relakkes) 2026-05-18 14:41:13 +08:00
commit 0506452a1b
6 changed files with 304 additions and 84 deletions

View File

@ -905,8 +905,8 @@ describe('MessageList nested tool calls', () => {
await selectMessageText(userText, 'workspace selection behavior')
const floatingAddButton = screen.getByRole('button', { name: 'Add to chat' })
expect(floatingAddButton.style.left).toBe('260px')
expect(floatingAddButton.style.top).toBe('112px')
expect(floatingAddButton.style.left).toBe('141px')
expect(floatingAddButton.style.top).toBe('26px')
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 () => {
const scrollIntoView = vi.fn()
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {

View File

@ -1,5 +1,5 @@
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 { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
import { useChatStore } from '../../stores/chatStore'
@ -22,6 +22,7 @@ import { InlineTaskSummary } from './InlineTaskSummary'
import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
import { ConfirmDialog } from '../shared/ConfirmDialog'
import { clearWindowSelection, getSelectionPopoverPosition, useSelectionPopoverDismiss } from '../../hooks/useSelectionPopoverDismiss'
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
@ -62,11 +63,9 @@ type ChatSelectionState = {
y: number
}
const CHAT_SELECTION_MENU_OFFSET = 8
function clampValue(value: number, min: number, max: number) {
return Math.max(min, Math.min(value, max))
}
const CHAT_SELECTION_MENU_OFFSET = 10
const CHAT_SELECTION_MENU_WIDTH = 158
const CHAT_SELECTION_MENU_HEIGHT = 44
function getElementForNode(node: Node | null): Element | 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 }) {
const rect = typeof range.getBoundingClientRect === 'function'
? range.getBoundingClientRect()
: null
const rootRect = root.getBoundingClientRect()
const pointerInsideRoot =
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),
}
return getSelectionPopoverPosition(range, root, {
menuWidth: CHAT_SELECTION_MENU_WIDTH,
menuHeight: CHAT_SELECTION_MENU_HEIGHT,
offset: CHAT_SELECTION_MENU_OFFSET,
fallbackPointer: pointer,
})
}
function getChatSelectionFromContainer(
@ -129,22 +108,25 @@ function getChatSelectionFromContainer(
function ChatSelectionMenu({
selection,
onAdd,
popoverRef,
}: {
selection: ChatSelectionState | null
onAdd: () => void
popoverRef: { current: HTMLButtonElement | null }
}) {
const t = useTranslation()
if (!selection) return null
return (
<button
ref={popoverRef}
type="button"
onMouseDown={(event) => event.preventDefault()}
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 }}
>
<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>
</button>
)
@ -299,6 +281,7 @@ function SelectableChatMessage({
children: ReactNode
}) {
const rootRef = useRef<HTMLDivElement>(null)
const selectionMenuRef = useRef<HTMLButtonElement>(null)
const addReference = useWorkspaceChatContextStore((state) => state.addReference)
const [selectionMenu, setSelectionMenu] = useState<ChatSelectionState | null>(null)
const t = useTranslation()
@ -310,6 +293,16 @@ function SelectableChatMessage({
setSelectionMenu(null)
}, [content, messageId])
const dismissSelectionMenu = useCallback(() => {
setSelectionMenu(null)
}, [])
useSelectionPopoverDismiss({
active: Boolean(selectionMenu),
popoverRef: selectionMenuRef,
onDismiss: dismissSelectionMenu,
})
const addCurrentSelectionToChat = useCallback(() => {
if (!sessionId || !selectionMenu) return
addReference(sessionId, {
@ -321,7 +314,7 @@ function SelectableChatMessage({
messageId,
})
setSelectionMenu(null)
window.getSelection()?.removeAllRanges()
clearWindowSelection()
}, [addReference, messageId, role, selectionMenu, sessionId, sourceName])
return (
@ -335,7 +328,7 @@ function SelectableChatMessage({
}}
>
{children}
<ChatSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} />
<ChatSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} popoverRef={selectionMenuRef} />
</div>
)
}

View File

@ -144,13 +144,13 @@ async function selectWorkspaceCodeText(
Object.assign(range, {
getBoundingClientRect: () => ({
left: 120,
top: 40,
top: 100,
right: 240,
bottom: 58,
bottom: 118,
width: 120,
height: 18,
x: 120,
y: 40,
y: 100,
toJSON: () => ({}),
}),
})
@ -160,7 +160,7 @@ async function selectWorkspaceCodeText(
selection?.addRange(range)
await act(async () => {
fireEvent.mouseUp(code, { clientX: 180, clientY: 72 })
fireEvent.mouseUp(code, { clientX: 180, clientY: 122 })
await Promise.resolve()
})
await flushReactWork()
@ -1444,8 +1444,8 @@ describe('WorkspacePanel', () => {
const addButtons = view.getAllByRole('button', { name: 'Add to chat' })
const floatingAddButton = addButtons[addButtons.length - 1]!
expect(floatingAddButton.style.left).toBe('180px')
expect(floatingAddButton.style.top).toBe('80px')
expect(floatingAddButton.style.left).toBe('101px')
expect(floatingAddButton.style.top).toBe('46px')
fireEvent.keyDown(view.getByTestId('workspace-code').parentElement?.parentElement ?? view.getByTestId('workspace-code'), {
key: 'Escape',
@ -1454,6 +1454,61 @@ describe('WorkspacePanel', () => {
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 () => {
await setWorkspaceState((state) => ({
...state,

View File

@ -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 type {
WorkspaceChangedFile,
@ -18,6 +19,7 @@ import { useChatStore } from '../../stores/chatStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
import { useUIStore } from '../../stores/uiStore'
import { copyTextToClipboard } from '../chat/clipboard'
import { clearWindowSelection, getSelectionPopoverPosition, useSelectionPopoverDismiss } from '../../hooks/useSelectionPopoverDismiss'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import {
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_PREVIEW_TABS: WorkspacePreviewTab[] = []
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 }> = {
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)]' },
@ -239,37 +243,13 @@ function getLineNumberFromNode(node: Node | null, root: HTMLElement) {
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) {
const rect = typeof range.getBoundingClientRect === 'function'
? range.getBoundingClientRect()
: null
const rootRect = root.getBoundingClientRect()
const pointerInsideRoot = 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),
}
return getSelectionPopoverPosition(range, root, {
menuWidth: SELECTION_MENU_WIDTH,
menuHeight: SELECTION_MENU_HEIGHT,
offset: SELECTION_MENU_OFFSET,
fallbackPointer: pointer,
})
}
function getTextSelectionFromContainer(
@ -321,22 +301,25 @@ function getLineRangeForText(value: string, text: string) {
function FloatingSelectionMenu({
selection,
onAdd,
popoverRef,
}: {
selection: FloatingSelectionMenuState | null
onAdd: () => void
popoverRef: { current: HTMLButtonElement | null }
}) {
const t = useTranslation()
if (!selection) return null
return (
<button
ref={popoverRef}
type="button"
onMouseDown={(event) => event.preventDefault()}
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 }}
>
<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>
</button>
)
@ -452,6 +435,7 @@ function CodeSurface({
}) {
const t = useTranslation()
const surfaceRef = useRef<HTMLDivElement>(null)
const selectionMenuRef = useRef<HTMLButtonElement>(null)
const [commentLine, setCommentLine] = useState<number | null>(null)
const [commentDraft, setCommentDraft] = useState('')
const [showAllLines, setShowAllLines] = useState(false)
@ -469,6 +453,16 @@ function CodeSurface({
setSelectionMenu(null)
}, [language, value])
const dismissSelectionMenu = useCallback(() => {
setSelectionMenu(null)
}, [])
useSelectionPopoverDismiss({
active: Boolean(selectionMenu),
popoverRef: selectionMenuRef,
onDismiss: dismissSelectionMenu,
})
const submitLineComment = () => {
if (!commentLine || !commentDraft.trim()) return
onAddLineComment(commentLine, commentDraft.trim(), activeQuote)
@ -497,7 +491,7 @@ function CodeSurface({
endLine: selectionMenu.endLine,
})
setSelectionMenu(null)
window.getSelection()?.removeAllRanges()
clearWindowSelection()
}
const renderLineCommentEditor = (lineNumber: number) => {
@ -650,7 +644,7 @@ function CodeSurface({
</div>
)}
</div>
<FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} />
<FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} popoverRef={selectionMenuRef} />
</div>
)
}
@ -663,12 +657,23 @@ function MarkdownSurface({
onAddSelection: (selection: WorkspaceTextSelection) => void
}) {
const surfaceRef = useRef<HTMLDivElement>(null)
const selectionMenuRef = useRef<HTMLButtonElement>(null)
const [selectionMenu, setSelectionMenu] = useState<FloatingSelectionMenuState | null>(null)
useEffect(() => {
setSelectionMenu(null)
}, [value])
const dismissSelectionMenu = useCallback(() => {
setSelectionMenu(null)
}, [])
useSelectionPopoverDismiss({
active: Boolean(selectionMenu),
popoverRef: selectionMenuRef,
onDismiss: dismissSelectionMenu,
})
const handleSelectionMouseUp = (event: MouseEvent<HTMLDivElement>) => {
setSelectionMenu(getTextSelectionFromContainer(
surfaceRef.current,
@ -685,7 +690,7 @@ function MarkdownSurface({
endLine: selectionMenu.endLine,
})
setSelectionMenu(null)
window.getSelection()?.removeAllRanges()
clearWindowSelection()
}
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"
/>
</div>
<FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} />
<FloatingSelectionMenu selection={selectionMenu} onAdd={addCurrentSelectionToChat} popoverRef={selectionMenuRef} />
</div>
)
}

View 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])
}

View File

@ -122,7 +122,7 @@ export const zh: Record<TranslationKey, string> = {
'workspace.showAllLoadedLines': '显示全部已加载行',
'workspace.collapsePreview': '收起预览',
'workspace.addToChat': '添加到聊天',
'workspace.addSelectionToChat': '添加对话',
'workspace.addSelectionToChat': '添加对话',
'workspace.copyPath': '复制路径',
'workspace.pathCopied': '路径已复制。',
'workspace.localComment': '本地评论',
@ -907,7 +907,7 @@ export const zh: Record<TranslationKey, string> = {
'chat.dropFilesHint': '会以文件路径附加到当前输入。',
'chat.workspaceReferencesOnly': '已添加 {count} 个工作区引用',
'chat.contextReferencesOnly': '已添加 {count} 个引用',
'chat.addSelectionToChat': '添加对话',
'chat.addSelectionToChat': '添加对话',
'chat.userMessageReference': '用户消息',
'chat.assistantMessageReference': 'AI 回复',
'chat.slashCommands': '斜杠命令',