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} 行', @@ -808,6 +809,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) => ({