From 52b11697e1360f0524c7768688f074423000e754 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 13 May 2026 14:08:12 +0800 Subject: [PATCH] feat: make selected context reusable in desktop chat Workspace and transcript selections now flow into the composer as lightweight references so users can carry exact snippets into the next prompt without file-only workarounds. The prompt formatter keeps workspace and chat excerpts separate, and the composer avoids sending chat snippets as fake file attachments. Constraint: Selection references must prepare model context without polluting backend file attachment payloads. Rejected: Reuse file attachments for chat selections | would leak chat:// pseudo paths into file-oriented payloads. Confidence: high Scope-risk: moderate Directive: Keep chat-selection references prompt-only unless the backend gains a first-class chat context attachment type. Tested: bun run check:desktop Tested: bun run verify Tested: agent-browser UI smoke for workspace, user-message, and assistant-message selections Not-tested: Strict Chrome extension channel; connection timed out, local browser automation covered the flow. --- .../chat/AttachmentGallery.test.tsx | 67 ++++ .../src/components/chat/AttachmentGallery.tsx | 32 +- desktop/src/components/chat/ChatInput.tsx | 28 +- .../src/components/chat/MessageList.test.tsx | 126 ++++++++ desktop/src/components/chat/MessageList.tsx | 178 ++++++++++- .../workspace/WorkspacePanel.test.tsx | 290 ++++++++++++++++++ .../components/workspace/WorkspacePanel.tsx | 240 ++++++++++++++- desktop/src/i18n/locales/en.ts | 5 + desktop/src/i18n/locales/zh.ts | 5 + desktop/src/stores/chatStore.test.ts | 6 +- .../stores/workspaceChatContextStore.test.ts | 46 ++- .../src/stores/workspaceChatContextStore.ts | 98 +++++- 12 files changed, 1066 insertions(+), 55 deletions(-) create mode 100644 desktop/src/components/chat/AttachmentGallery.test.tsx diff --git a/desktop/src/components/chat/AttachmentGallery.test.tsx b/desktop/src/components/chat/AttachmentGallery.test.tsx new file mode 100644 index 00000000..26419659 --- /dev/null +++ b/desktop/src/components/chat/AttachmentGallery.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom + +import { fireEvent, render } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { AttachmentGallery } from './AttachmentGallery' + +describe('AttachmentGallery', () => { + it('renders a compact quote preview for selected workspace text', () => { + render( + , + ) + + expect(document.body.textContent).toContain('App.tsx:L10-L12') + expect(document.body.textContent).toContain('const value = calculate(input) return value') + }) + + it('keeps plain file chips on the one-line treatment', () => { + render( + , + ) + + expect(document.body.textContent).toContain('README.md') + expect(document.body.textContent).not.toContain(':L') + }) + + it('removes a quoted workspace attachment by id', () => { + const onRemove = vi.fn() + + const view = render( + , + ) + + fireEvent.click(view.getByRole('button', { name: 'Remove App.tsx' })) + + expect(onRemove).toHaveBeenCalledWith('selection-1') + }) +}) diff --git a/desktop/src/components/chat/AttachmentGallery.tsx b/desktop/src/components/chat/AttachmentGallery.tsx index 7733766d..154b0ff2 100644 --- a/desktop/src/components/chat/AttachmentGallery.tsx +++ b/desktop/src/components/chat/AttachmentGallery.tsx @@ -83,29 +83,41 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove } ) } + const lineLabel = attachment.lineStart + ? `:L${attachment.lineStart}${attachment.lineEnd && attachment.lineEnd !== attachment.lineStart ? `-L${attachment.lineEnd}` : ''}` + : '' + const quotePreview = attachment.quote?.trim().replace(/\s+/g, ' ') + const hasQuotePreview = !!quotePreview + return (
- - {attachment.lineStart ? 'chat_bubble' : attachment.isDirectory ? 'folder' : 'description'} + + {hasQuotePreview ? 'chat_bubble' : attachment.isDirectory ? 'folder' : 'description'} - - {attachment.name} - {attachment.lineStart - ? `:L${attachment.lineStart}${attachment.lineEnd && attachment.lineEnd !== attachment.lineStart ? `-L${attachment.lineEnd}` : ''}` - : ''} + + + {attachment.name}{lineLabel} + + {hasQuotePreview && ( + + {quotePreview} + + )} {onRemove && attachment.id && ( + ) +} + +function SelectableChatMessage({ + sessionId, + messageId, + role, + content, + children, +}: { + sessionId?: string | null + messageId: string + role: ChatMessageRole + content: string + children: ReactNode +}) { + const rootRef = useRef(null) + const addReference = useWorkspaceChatContextStore((state) => state.addReference) + const [selectionMenu, setSelectionMenu] = useState(null) + const t = useTranslation() + const sourceName = role === 'assistant' + ? t('chat.assistantMessageReference') + : t('chat.userMessageReference') + + useEffect(() => { + setSelectionMenu(null) + }, [content, messageId]) + + const addCurrentSelectionToChat = useCallback(() => { + if (!sessionId || !selectionMenu) return + addReference(sessionId, { + kind: 'chat-selection', + path: `chat://${role}/${messageId}`, + name: sourceName, + quote: selectionMenu.text, + sourceRole: role, + messageId, + }) + setSelectionMenu(null) + window.getSelection()?.removeAllRanges() + }, [addReference, messageId, role, selectionMenu, sessionId, sourceName]) + + return ( +
{ + setSelectionMenu(getChatSelectionFromContainer(rootRef.current, event)) + }} + onKeyDown={(event) => { + if (event.key === 'Escape') setSelectionMenu(null) + }} + > + {children} + +
+ ) +} + function appendChildToolCall( childToolCallsByParent: Map, parentToolUseId: string, @@ -671,13 +823,29 @@ export const MessageBlock = memo(function MessageBlock({ switch (message.type) { case 'user_text': return ( - + > + + ) case 'assistant_text': - return + return ( + + + + ) case 'thinking': return case 'tool_use': diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx index 88f2f052..a0873feb 100644 --- a/desktop/src/components/workspace/WorkspacePanel.test.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -98,6 +98,118 @@ async function clickElement(element: Element) { await flushReactWork() } +function findTextNodeContaining(container: Element, text: string) { + const walker = document.createTreeWalker(container, 4) + let current = walker.nextNode() + while (current) { + if (current.textContent?.includes(text)) return current + current = walker.nextNode() + } + throw new Error(`Unable to find text node containing ${text}`) +} + +async function selectWorkspaceCodeText( + view: Awaited>, + startLine: number, + startText: string, + endLine: number, + endText: string, +) { + const code = view.getByTestId('workspace-code') + const startRow = code.querySelector(`[data-workspace-line-number="${startLine}"]`) + const endRow = code.querySelector(`[data-workspace-line-number="${endLine}"]`) + if (!startRow || !endRow) throw new Error('Selection rows were not rendered') + + Object.assign(code.parentElement?.parentElement ?? code, { + getBoundingClientRect: () => ({ + left: 100, + top: 24, + right: 520, + bottom: 420, + width: 420, + height: 396, + x: 100, + y: 24, + toJSON: () => ({}), + }), + }) + + const startNode = findTextNodeContaining(startRow, startText) + const endNode = findTextNodeContaining(endRow, endText) + const startOffset = startNode.textContent?.indexOf(startText) ?? -1 + const endOffset = (endNode.textContent?.indexOf(endText) ?? -1) + endText.length + const range = document.createRange() + range.setStart(startNode, startOffset) + range.setEnd(endNode, endOffset) + Object.assign(range, { + getBoundingClientRect: () => ({ + left: 120, + top: 40, + right: 240, + bottom: 58, + width: 120, + height: 18, + x: 120, + y: 40, + toJSON: () => ({}), + }), + }) + + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + + await act(async () => { + fireEvent.mouseUp(code, { clientX: 180, clientY: 72 }) + await Promise.resolve() + }) + await flushReactWork() +} + +async function selectRenderedText(container: Element, text: string, target?: Element) { + const textNode = findTextNodeContaining(container, text) + const startOffset = textNode.textContent?.indexOf(text) ?? -1 + const range = document.createRange() + range.setStart(textNode, startOffset) + range.setEnd(textNode, startOffset + text.length) + Object.assign(range, { + getBoundingClientRect: () => ({ + left: 130, + top: 60, + right: 260, + bottom: 78, + width: 130, + height: 18, + x: 130, + y: 60, + toJSON: () => ({}), + }), + }) + Object.assign(target ?? container, { + getBoundingClientRect: () => ({ + left: 100, + top: 24, + right: 520, + bottom: 420, + width: 420, + height: 396, + x: 100, + y: 24, + toJSON: () => ({}), + }), + }) + + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + + await act(async () => { + fireEvent.mouseUp(target ?? container, { clientX: 190, clientY: 80 }) + await Promise.resolve() + }) + await flushReactWork() +} + function classNameContains(element: Element | null, needle: string) { let current = element while (current) { @@ -1145,6 +1257,184 @@ describe('WorkspacePanel', () => { ]) }) + it('adds selected code from a preview to the chat context without requiring a note', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-code-selection': { + isOpen: true, + activeView: 'all', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-code-selection': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-code-selection': [{ + 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-code-selection': 'file:src/App.ts', + }, + })) + + const view = await renderPanel('session-code-selection') + + await selectWorkspaceCodeText(view, 1, 'const title = "Todo"', 2, 'export default title') + const addButtons = view.getAllByRole('button', { name: 'Add to chat' }) + await clickElement(addButtons[addButtons.length - 1]!) + + expect(useWorkspaceChatContextStore.getState().referencesBySession['session-code-selection']).toMatchObject([ + { + kind: 'code-selection', + path: 'src/App.ts', + absolutePath: '/repo/src/App.ts', + name: 'App.ts', + lineStart: 1, + lineEnd: 2, + quote: 'const title = "Todo"\nexport default title', + }, + ]) + expect(useWorkspaceChatContextStore.getState().referencesBySession['session-code-selection']?.[0]?.note).toBeUndefined() + }) + + it('keeps the selected-code action near the preview instead of the file tree', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-selection-position': { + isOpen: true, + activeView: 'all', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-selection-position': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-selection-position': [{ + 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-position': 'file:src/App.ts', + }, + })) + + const view = await renderPanel('session-selection-position') + + await selectWorkspaceCodeText(view, 1, 'const title = "Todo"', 1, 'const title = "Todo"') + 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') + + fireEvent.keyDown(view.getByTestId('workspace-code').parentElement?.parentElement ?? view.getByTestId('workspace-code'), { + key: 'Escape', + }) + await flushReactWork() + expect(view.queryAllByRole('button', { name: 'Add to chat' })).toHaveLength(1) + }) + + it('adds selected markdown text from a preview to the chat context', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-markdown-selection': { + isOpen: true, + activeView: 'all', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-markdown-selection': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-markdown-selection': [{ + id: 'file:docs/guide.md', + path: 'docs/guide.md', + kind: 'file', + title: 'guide.md', + language: 'markdown', + content: '# Guide\nAlpha note and Beta note', + state: 'ok', + size: 33, + }], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + 'session-markdown-selection': 'file:docs/guide.md', + }, + })) + + const view = await renderPanel('session-markdown-selection') + const paragraph = view.getByText('Alpha note and Beta note') + const markdownSurface = paragraph.closest('.min-h-0') ?? paragraph + + await selectRenderedText(paragraph, 'Alpha note and Beta note', markdownSurface) + const addButtons = view.getAllByRole('button', { name: 'Add to chat' }) + const floatingAddButton = addButtons[addButtons.length - 1]! + await clickElement(floatingAddButton) + + expect(useWorkspaceChatContextStore.getState().referencesBySession['session-markdown-selection']).toMatchObject([ + { + kind: 'code-selection', + path: 'docs/guide.md', + absolutePath: '/repo/docs/guide.md', + name: 'guide.md', + lineStart: 2, + lineEnd: 2, + quote: 'Alpha note and Beta note', + }, + ]) + }) + it('uses the localized view menu label', async () => { await setSettingsState({ ...settingsInitialState, locale: 'zh' }) await setWorkspaceState((state) => ({ diff --git a/desktop/src/components/workspace/WorkspacePanel.tsx b/desktop/src/components/workspace/WorkspacePanel.tsx index bcc46d06..ada3f69e 100644 --- a/desktop/src/components/workspace/WorkspacePanel.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.tsx @@ -82,6 +82,7 @@ const FILE_STATUS_META: Record = {} const EMPTY_PREVIEW_TABS: WorkspacePreviewTab[] = [] const EMPTY_EXPANDED_PATHS: string[] = [] +const SELECTION_MENU_OFFSET = 8 const FILE_BADGE_META: Record = { 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)]' }, @@ -207,6 +208,138 @@ function treeEntryMatchesFilter( return childTree.entries.some((child) => treeEntryMatchesFilter(child, query, treeByPath)) } +type WorkspaceTextSelection = { + text: string + startLine?: number + endLine?: number +} + +type FloatingSelectionMenuState = WorkspaceTextSelection & { + x: number + y: number +} + +type SelectionPointer = { + clientX: number + clientY: number +} + +function getElementForNode(node: Node | null): Element | null { + if (!node) return null + return node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement +} + +function getLineNumberFromNode(node: Node | null, root: HTMLElement) { + const element = getElementForNode(node) + const row = element?.closest('[data-workspace-line-number]') + if (!row || !root.contains(row)) return undefined + const line = Number(row.getAttribute('data-workspace-line-number')) + 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), + } +} + +function getTextSelectionFromContainer( + root: HTMLElement | null, + resolveLines?: (text: string, range: Range) => { startLine?: number; endLine?: number }, + pointer?: SelectionPointer, +): FloatingSelectionMenuState | null { + if (!root) return null + + const selection = window.getSelection() + if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null + + const range = selection.getRangeAt(0) + const startElement = getElementForNode(range.startContainer) + const endElement = getElementForNode(range.endContainer) + if (!startElement || !endElement || !root.contains(startElement) || !root.contains(endElement)) { + return null + } + + const text = selection.toString().trim() + if (!text) return null + + const nodeLines = { + startLine: getLineNumberFromNode(range.startContainer, root), + endLine: getLineNumberFromNode(range.endContainer, root), + } + const resolvedLines = resolveLines?.(text, range) ?? nodeLines + const startLine = resolvedLines.startLine ?? nodeLines.startLine + const endLine = resolvedLines.endLine ?? nodeLines.endLine ?? startLine + const orderedStart = startLine && endLine ? Math.min(startLine, endLine) : startLine + const orderedEnd = startLine && endLine ? Math.max(startLine, endLine) : endLine + + return { + ...getSelectionPosition(range, root, pointer), + text, + ...(orderedStart ? { startLine: orderedStart } : {}), + ...(orderedEnd ? { endLine: orderedEnd } : {}), + } +} + +function getLineRangeForText(value: string, text: string) { + const index = value.indexOf(text) + if (index < 0) return {} + const startLine = value.slice(0, index).split('\n').length + const endLine = startLine + text.split('\n').length - 1 + return { startLine, endLine } +} + +function FloatingSelectionMenu({ + selection, + onAdd, +}: { + selection: FloatingSelectionMenuState | null + onAdd: () => void +}) { + const t = useTranslation() + if (!selection) return null + + return ( + + ) +} + function PanelMessage({ icon, message, @@ -308,15 +441,19 @@ function CodeSurface({ value, language, onAddLineComment, + onAddSelection, }: { value: string language: string onAddLineComment: (line: number, note: string, quote: string) => void + onAddSelection: (selection: WorkspaceTextSelection) => void }) { const t = useTranslation() + const surfaceRef = useRef(null) const [commentLine, setCommentLine] = useState(null) const [commentDraft, setCommentDraft] = useState('') const [showAllLines, setShowAllLines] = useState(false) + const [selectionMenu, setSelectionMenu] = useState(null) const lines = value.split('\n') const visibleLines = showAllLines ? lines : lines.slice(0, WORKSPACE_PREVIEW_LINE_LIMIT) const activeQuote = commentLine ? visibleLines[commentLine - 1] ?? '' : '' @@ -327,6 +464,7 @@ function CodeSurface({ setShowAllLines(false) setCommentLine(null) setCommentDraft('') + setSelectionMenu(null) }, [language, value]) const submitLineComment = () => { @@ -336,6 +474,30 @@ function CodeSurface({ setCommentDraft('') } + const handleSelectionMouseUp = (event: MouseEvent) => { + const selection = getTextSelectionFromContainer(surfaceRef.current, undefined, event) + if (!selection?.startLine || !selection.endLine || selection.startLine === selection.endLine) { + setSelectionMenu(selection) + return + } + + setSelectionMenu({ + ...selection, + text: visibleLines.slice(selection.startLine - 1, selection.endLine).join('\n').trim(), + }) + } + + const addCurrentSelectionToChat = () => { + if (!selectionMenu) return + onAddSelection({ + text: selectionMenu.text, + startLine: selectionMenu.startLine, + endLine: selectionMenu.endLine, + }) + setSelectionMenu(null) + window.getSelection()?.removeAllRanges() + } + const renderLineCommentEditor = (lineNumber: number) => { if (commentLine !== lineNumber) return null @@ -398,7 +560,14 @@ function CodeSurface({ ) return ( -
+
{ + if (event.key === 'Escape') setSelectionMenu(null) + }} + >
{usePlainLargePreview ? (
-                  
+
{renderLineNumberButton(lineNumber)} {line || ' '}
@@ -440,6 +612,7 @@ function CodeSurface({
{renderLineNumberButton(lineNumber)} @@ -475,13 +648,53 @@ function CodeSurface({
)}
+
) } -function MarkdownSurface({ value }: { value: string }) { +function MarkdownSurface({ + value, + onAddSelection, +}: { + value: string + onAddSelection: (selection: WorkspaceTextSelection) => void +}) { + const surfaceRef = useRef(null) + const [selectionMenu, setSelectionMenu] = useState(null) + + useEffect(() => { + setSelectionMenu(null) + }, [value]) + + const handleSelectionMouseUp = (event: MouseEvent) => { + setSelectionMenu(getTextSelectionFromContainer( + surfaceRef.current, + (text) => getLineRangeForText(value, text), + event, + )) + } + + const addCurrentSelectionToChat = () => { + if (!selectionMenu) return + onAddSelection({ + text: selectionMenu.text, + startLine: selectionMenu.startLine, + endLine: selectionMenu.endLine, + }) + setSelectionMenu(null) + window.getSelection()?.removeAllRanges() + } + return ( -
+
{ + if (event.key === 'Escape') setSelectionMenu(null) + }} + >
+
) } @@ -815,6 +1029,18 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) { }) } + const addSelectionToChat = (path: string, selection: WorkspaceTextSelection) => { + addWorkspaceReference(sessionId, { + kind: 'code-selection', + path, + absolutePath: resolveWorkspaceAttachmentPath(status?.workDir, path), + name: path.split('/').pop() || path, + lineStart: selection.startLine, + lineEnd: selection.endLine, + quote: selection.text, + }) + } + const handleSetActiveView = (view: 'changed' | 'all') => { setActiveView(sessionId, view) setIsViewMenuOpen(false) @@ -996,12 +1222,16 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) { path={activePreviewTab.path} /> ) : state === 'ok' && isMarkdownPreview(activePreviewTab) ? ( - + addSelectionToChat(activePreviewTab.path, selection)} + /> ) : state === 'ok' ? ( addLineCommentToChat(activePreviewTab.path, line, note, quote)} + onAddSelection={(selection) => addSelectionToChat(activePreviewTab.path, selection)} /> ) : ( = { 'workspace.showAllLoadedLines': '显示全部已加载行', 'workspace.collapsePreview': '收起预览', 'workspace.addToChat': '添加到聊天', + 'workspace.addSelectionToChat': '添加至对话', 'workspace.copyPath': '复制路径', 'workspace.localComment': '本地评论', 'workspace.commentLine': '评论第 {line} 行', @@ -807,6 +808,10 @@ export const zh: Record = { 'chat.placeholderMissing': '此会话指向的工作目录缺失。请新建会话或选择其他项目。', 'chat.addFiles': '添加文件或图片', 'chat.workspaceReferencesOnly': '已添加 {count} 个工作区引用', + 'chat.contextReferencesOnly': '已添加 {count} 个引用', + 'chat.addSelectionToChat': '添加至对话', + 'chat.userMessageReference': '用户消息', + 'chat.assistantMessageReference': 'AI 回复', 'chat.slashCommands': '斜杠命令', 'slash.mcp.title': '可用 MCP 工具', 'slash.mcp.subtitle': '展示当前聊天上下文里的全局 MCP 和当前项目 MCP。', diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 7a0de26e..d2fb7da1 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -449,7 +449,7 @@ describe('chatStore history mapping', () => { useChatStore.getState().sendMessage( TEST_SESSION_ID, - 'Notes for attached workspace files:\n- src/App.tsx:L4\n Comment: tighten this', + 'Referenced workspace context:\n@"src/App.tsx:L4":\nComment: tighten this\n```tsx\nconst value = 1\n```', [{ type: 'file', name: 'App.tsx', @@ -477,7 +477,7 @@ describe('chatStore history mapping', () => { { type: 'user_text', content: '改这里', - modelContent: '@"/repo/src/App.tsx" Notes for attached workspace files:\n- src/App.tsx:L4\n Comment: tighten this', + modelContent: '@"/repo/src/App.tsx" Referenced workspace context:\n@"src/App.tsx:L4":\nComment: tighten this\n```tsx\nconst value = 1\n```', attachments: [{ type: 'file', name: 'App.tsx', @@ -493,7 +493,7 @@ describe('chatStore history mapping', () => { TEST_SESSION_ID, { type: 'user_message', - content: 'Notes for attached workspace files:\n- src/App.tsx:L4\n Comment: tighten this', + content: 'Referenced workspace context:\n@"src/App.tsx:L4":\nComment: tighten this\n```tsx\nconst value = 1\n```', attachments: [{ type: 'file', name: 'App.tsx', diff --git a/desktop/src/stores/workspaceChatContextStore.test.ts b/desktop/src/stores/workspaceChatContextStore.test.ts index 6194d523..0edc0f9e 100644 --- a/desktop/src/stores/workspaceChatContextStore.test.ts +++ b/desktop/src/stores/workspaceChatContextStore.test.ts @@ -45,14 +45,54 @@ describe('workspaceChatContextStore', () => { }, ]) - expect(prompt).toContain('Notes for attached workspace files:') - expect(prompt).toContain('- src/App.tsx:L12') + expect(prompt).toContain('Referenced workspace context:') + expect(prompt).toContain('@"src/App.tsx:L12":') expect(prompt).toContain('Comment: Use a clearer name') - expect(prompt).toContain('Selected code: const value = 1') + expect(prompt).toContain('```tsx\nconst value = 1\n```') expect(prompt).not.toContain('Use the Read tool') expect(prompt).not.toContain('Path: /repo/src/App.tsx') }) + it('formats selected code without requiring a comment', () => { + const prompt = formatWorkspaceReferencePrompt([ + { + id: 'ref-1', + kind: 'code-selection', + path: 'src/App.tsx', + absolutePath: '/repo/src/App.tsx', + name: 'App.tsx', + lineStart: 10, + lineEnd: 12, + quote: 'const value = 1\nreturn value', + }, + ]) + + expect(prompt).toContain('Referenced workspace context:') + expect(prompt).toContain('@"src/App.tsx:L10-L12":') + expect(prompt).toContain('```tsx\nconst value = 1\nreturn value\n```') + expect(prompt).not.toContain('Comment:') + }) + + it('formats selected chat messages as chat context instead of file context', () => { + const prompt = formatWorkspaceReferencePrompt([ + { + id: 'chat-ref-1', + kind: 'chat-selection', + path: 'chat://assistant/assistant-1', + name: 'Assistant message', + messageId: 'assistant-1', + sourceRole: 'assistant', + quote: 'Use the workspace panel selection menu.', + }, + ]) + + expect(prompt).toContain('Referenced chat context:') + expect(prompt).toContain('Assistant message:') + expect(prompt).toContain('```\nUse the workspace panel selection menu.\n```') + expect(prompt).not.toContain('@"chat://assistant/assistant-1"') + expect(prompt).not.toContain('Referenced workspace context:') + }) + it('does not add prompt text for plain file attachments', () => { const prompt = formatWorkspaceReferencePrompt([ { diff --git a/desktop/src/stores/workspaceChatContextStore.ts b/desktop/src/stores/workspaceChatContextStore.ts index ab889563..60e70bb4 100644 --- a/desktop/src/stores/workspaceChatContextStore.ts +++ b/desktop/src/stores/workspaceChatContextStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand' -export type WorkspaceChatReferenceKind = 'file' | 'code-comment' +export type WorkspaceChatReferenceKind = 'file' | 'code-comment' | 'code-selection' | 'chat-selection' export type WorkspaceChatReference = { id: string @@ -13,6 +13,8 @@ export type WorkspaceChatReference = { lineEnd?: number note?: string quote?: string + sourceRole?: 'user' | 'assistant' + messageId?: string } type WorkspaceChatContextStore = { @@ -29,17 +31,29 @@ type WorkspaceChatContextStore = { function makeReferenceId(reference: Omit) { const linePart = reference.lineStart ? `${reference.lineStart}-${reference.lineEnd ?? reference.lineStart}` - : 'file' - const notePart = reference.note ? reference.note.slice(0, 48) : '' + : reference.messageId ?? 'file' + const notePart = (reference.note?.trim() || reference.quote?.trim() || '').slice(0, 48) return `${reference.kind}:${reference.path}:${linePart}:${notePart}` } function getReferenceDedupKey(reference: WorkspaceChatReference) { if (reference.kind === 'file') return `${reference.kind}:${reference.path}` - return `${reference.kind}:${reference.path}:${reference.lineStart ?? ''}:${reference.lineEnd ?? ''}:${reference.note ?? ''}` + return [ + reference.kind, + reference.path, + reference.messageId ?? '', + reference.sourceRole ?? '', + reference.lineStart ?? '', + reference.lineEnd ?? '', + reference.note?.trim() ?? '', + reference.quote?.trim() ?? '', + ].join(':') } export function formatWorkspaceReferenceLocation(reference: WorkspaceChatReference) { + if (reference.kind === 'chat-selection') { + return reference.sourceRole === 'assistant' ? 'Assistant message' : 'User message' + } if (!reference.lineStart) return reference.path const lineEnd = reference.lineEnd && reference.lineEnd !== reference.lineStart ? `-L${reference.lineEnd}` @@ -47,27 +61,79 @@ export function formatWorkspaceReferenceLocation(reference: WorkspaceChatReferen return `${reference.path}:L${reference.lineStart}${lineEnd}` } +function getFenceForQuote(quote: string) { + const runs = quote.match(/`+/g) ?? [] + const longestRun = runs.reduce((max, run) => Math.max(max, run.length), 0) + return '`'.repeat(Math.max(3, longestRun + 1)) +} + +function getLanguageHint(reference: WorkspaceChatReference) { + if (reference.kind === 'chat-selection') return '' + const extension = reference.name.split('.').pop()?.toLowerCase() + if (!extension || extension === reference.name.toLowerCase()) return '' + const aliases: Record = { + js: 'javascript', + jsx: 'jsx', + ts: 'typescript', + tsx: 'tsx', + md: 'markdown', + py: 'python', + rb: 'ruby', + rs: 'rust', + sh: 'bash', + yml: 'yaml', + } + return aliases[extension] ?? extension +} + export function formatWorkspaceReferencePrompt(references: WorkspaceChatReference[]) { - const referencesWithContext = references.filter((reference) => + const workspaceReferencesWithContext = references.filter((reference) => reference.kind === 'code-comment' || + reference.kind === 'code-selection' || !!reference.lineStart || !!reference.note?.trim() || !!reference.quote?.trim(), + ).filter((reference) => reference.kind !== 'chat-selection') + const chatReferencesWithContext = references.filter((reference) => + reference.kind === 'chat-selection' && !!reference.quote?.trim(), ) - if (referencesWithContext.length === 0) return '' + if (workspaceReferencesWithContext.length === 0 && chatReferencesWithContext.length === 0) return '' - const lines = [ - 'Notes for attached workspace files:', - ...referencesWithContext.map((reference) => { + const workspaceLines = workspaceReferencesWithContext.length > 0 + ? [ + 'Referenced workspace context:', + ...workspaceReferencesWithContext.map((reference) => { + const location = formatWorkspaceReferenceLocation(reference) + const parts = [`@"${location}":`] + if (reference.note?.trim()) parts.push(`Comment: ${reference.note.trim()}`) + if (reference.quote?.trim()) { + const fence = getFenceForQuote(reference.quote) + const languageHint = getLanguageHint(reference) + parts.push(`${fence}${languageHint}`) + parts.push(reference.quote.trim()) + parts.push(fence) + } + return parts.join('\n') + }), + ] + : [] + const chatLines = chatReferencesWithContext.length > 0 + ? [ + 'Referenced chat context:', + ...chatReferencesWithContext.map((reference) => { const location = formatWorkspaceReferenceLocation(reference) - const parts = [`- ${location}`] - if (reference.note?.trim()) parts.push(`Comment: ${reference.note.trim()}`) - if (reference.quote?.trim()) parts.push(`Selected code: ${reference.quote.trim()}`) - return parts.join('\n ') - }), - ] + const fence = getFenceForQuote(reference.quote ?? '') + return [ + `${location}:`, + fence, + reference.quote?.trim() ?? '', + fence, + ].join('\n') + }), + ] + : [] - return lines.join('\n') + return [...workspaceLines, ...chatLines].join('\n') } export const useWorkspaceChatContextStore = create((set) => ({