diff --git a/desktop/src/components/chat/AttachmentGallery.tsx b/desktop/src/components/chat/AttachmentGallery.tsx index 484dfda4..2d83e685 100644 --- a/desktop/src/components/chat/AttachmentGallery.tsx +++ b/desktop/src/components/chat/AttachmentGallery.tsx @@ -5,8 +5,13 @@ export type AttachmentPreview = { id?: string type: 'image' | 'file' name: string + path?: string data?: string previewUrl?: string + lineStart?: number + lineEnd?: number + note?: string + quote?: string } type Props = { @@ -35,7 +40,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove } return ( <> -
+
{attachments.map((attachment, index) => { if (attachment.type === 'image' && (attachment.previewUrl || attachment.data)) { const src = attachment.previewUrl || attachment.data || '' @@ -80,18 +85,29 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove } return (
- attach_file - {attachment.name} + + {attachment.lineStart ? 'chat_bubble' : 'description'} + + + {attachment.name} + {attachment.lineStart + ? `:L${attachment.lineStart}${attachment.lineEnd && attachment.lineEnd !== attachment.lineStart ? `-L${attachment.lineEnd}` : ''}` + : ''} + {onRemove && attachment.id && ( )}
diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 5b9a3afe..ad884be1 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -6,6 +6,11 @@ import { useUIStore } from '../../stores/uiStore' import { useSessionStore } from '../../stores/sessionStore' import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore' import { useTeamStore } from '../../stores/teamStore' +import { + formatWorkspaceReferencePrompt, + useWorkspaceChatContextStore, + type WorkspaceChatReference, +} from '../../stores/workspaceChatContextStore' import { sessionsApi } from '../../api/sessions' import { PermissionModeSelector } from '../controls/PermissionModeSelector' import { ModelSelector } from '../controls/ModelSelector' @@ -29,9 +34,14 @@ type Attachment = { id: string name: string type: 'image' | 'file' + path?: string mimeType?: string previewUrl?: string data?: string + lineStart?: number + lineEnd?: number + note?: string + quote?: string } type ChatInputProps = { @@ -39,6 +49,21 @@ type ChatInputProps = { compact?: boolean } +const EMPTY_WORKSPACE_REFERENCES: WorkspaceChatReference[] = [] + +function workspaceReferenceToAttachment(reference: WorkspaceChatReference): Attachment { + return { + id: reference.id, + name: reference.name, + type: 'file', + path: reference.path, + lineStart: reference.lineStart, + lineEnd: reference.lineEnd, + note: reference.note, + quote: reference.quote, + } +} + export function ChatInput({ variant = 'default', compact = false }: ChatInputProps) { const t = useTranslation() const [input, setInput] = useState('') @@ -68,13 +93,27 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null) const [gitInfo, setGitInfo] = useState(null) const hasMessages = useChatStore((s) => activeTabId ? (s.sessions[activeTabId]?.messages?.length ?? 0) > 0 : false) + const workspaceReferences = useWorkspaceChatContextStore( + (s) => activeTabId ? s.referencesBySession[activeTabId] ?? EMPTY_WORKSPACE_REFERENCES : EMPTY_WORKSPACE_REFERENCES, + ) + const addWorkspaceReference = useWorkspaceChatContextStore((s) => s.addReference) + const removeWorkspaceReference = useWorkspaceChatContextStore((s) => s.removeReference) + const clearWorkspaceReferences = useWorkspaceChatContextStore((s) => s.clearReferences) const isMemberSession = !!memberInfo const isActive = chatState !== 'idle' const isWorkspaceMissing = activeSession?.workDirExists === false - const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && attachments.length > 0)) + const hasWorkspaceReferences = !isMemberSession && workspaceReferences.length > 0 + const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && (attachments.length > 0 || hasWorkspaceReferences))) const isHeroComposer = variant === 'hero' && !isMemberSession && !compact const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined + const composerAttachments = useMemo( + () => [ + ...attachments, + ...workspaceReferences.map(workspaceReferenceToAttachment), + ], + [attachments, workspaceReferences], + ) useEffect(() => { textareaRef.current?.focus() @@ -303,7 +342,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const handleSubmit = () => { const text = input.trim() - if ((!text && (!attachments.length || isMemberSession)) || isWorkspaceMissing) return + if ((!text && ((!attachments.length && !hasWorkspaceReferences) || isMemberSession)) || isWorkspaceMissing) return const slashUiAction = !isMemberSession && text.startsWith('/') ? resolveSlashUiAction(text.slice(1)) : null if (slashUiAction?.type === 'panel') { @@ -325,16 +364,55 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro return } - const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({ + const workspaceReferencePrompt = !isMemberSession + ? formatWorkspaceReferencePrompt(workspaceReferences) + : '' + const contentForModel = [workspaceReferencePrompt, text].filter(Boolean).join('\n\n') + const displayContent = text || ( + workspaceReferences.length > 0 + ? t('chat.workspaceReferencesOnly', { count: workspaceReferences.length }) + : '' + ) + const uploadAttachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({ type: attachment.type, name: attachment.name, + path: attachment.path, data: attachment.data, mimeType: attachment.mimeType, + lineStart: attachment.lineStart, + lineEnd: attachment.lineEnd, + note: attachment.note, + quote: attachment.quote, })) + const workspaceAttachmentPayload: AttachmentRef[] = workspaceReferences.map((reference) => ({ + type: 'file' as const, + name: reference.name, + path: reference.absolutePath ?? reference.path, + lineStart: reference.lineStart, + lineEnd: reference.lineEnd, + note: reference.note, + quote: reference.quote, + })) + const visibleAttachmentPayload: AttachmentRef[] = [ + ...uploadAttachmentPayload, + ...workspaceReferences.map((reference) => ({ + type: 'file' as const, + name: reference.name, + path: reference.path, + lineStart: reference.lineStart, + lineEnd: reference.lineEnd, + note: reference.note, + quote: reference.quote, + })), + ] - sendMessage(activeTabId!, text, attachmentPayload) + sendMessage(activeTabId!, contentForModel, [...uploadAttachmentPayload, ...workspaceAttachmentPayload], { + displayContent, + displayAttachments: visibleAttachmentPayload, + }) setInput('') setAttachments([]) + if (!isMemberSession) clearWorkspaceReferences(activeTabId!) setPlusMenuOpen(false) setSlashMenuOpen(false) setFileSearchOpen(false) @@ -488,6 +566,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const removeAttachment = (id: string) => { setAttachments((prev) => prev.filter((attachment) => attachment.id !== id)) + if (activeTabId) removeWorkspaceReference(activeTabId, id) } const insertSlashCommand = () => { @@ -550,11 +629,19 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro ref={fileSearchRef} cwd={resolvedWorkDir || ''} filter={atFilter} - onSelect={(_path, name) => { + onSelect={(path, name) => { if (atCursorPos >= 0) { // Insert name at cursor position, replacing filter text const newValue = `${input.slice(0, atCursorPos)}${name}${input.slice(atCursorPos)}` const newCursorPos = atCursorPos + name.length + if (activeTabId) { + addWorkspaceReference(activeTabId, { + kind: 'file', + path, + absolutePath: path, + name, + }) + } setInput(newValue) setFileSearchOpen(false) setAtFilter('') @@ -618,12 +705,12 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
)} - {attachments.length > 0 && ( + {composerAttachments.length > 0 && ( isHeroComposer ? ( - + ) : (
- +
) )} diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index c24749d2..d0966d77 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -662,6 +662,7 @@ describe('MessageList nested tool calls', () => { id: 'user-2', type: 'user_text', content: '第二段', + modelContent: '@"/tmp/example-project/src/App.tsx" 第二段', timestamp: 3, }, { @@ -686,7 +687,7 @@ describe('MessageList nested tool calls', () => { expect(sessionsApi.rewind).toHaveBeenCalledWith(ACTIVE_TAB, { targetUserMessageId: 'user-2', userMessageIndex: 1, - expectedContent: '第二段', + expectedContent: '@"/tmp/example-project/src/App.tsx" 第二段', dryRun: true, }) }) diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index f299a782..8fd854c0 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -38,6 +38,7 @@ type RewindTurnTarget = { messageId: string userMessageIndex: number content: string + expectedContent: string attachments?: Extract['attachments'] } @@ -129,6 +130,7 @@ export function getLatestCompletedTurnTarget(messages: UIMessage[]): RewindTurnT messageId: message.id, userMessageIndex, content: message.content, + expectedContent: message.modelContent ?? message.content, attachments: message.attachments, messageOffset, } @@ -240,7 +242,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { sessionsApi.rewind(resolvedSessionId, { targetUserMessageId: latestTurnTarget.messageId, userMessageIndex: latestTurnTarget.userMessageIndex, - expectedContent: latestTurnTarget.content, + expectedContent: latestTurnTarget.expectedContent, dryRun: true, }), sessionsApi.getWorkspaceStatus(resolvedSessionId).catch(() => null), @@ -295,7 +297,7 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = { const result = await sessionsApi.rewind(resolvedSessionId, { targetUserMessageId: target.messageId, userMessageIndex: target.userMessageIndex, - expectedContent: target.content, + expectedContent: target.expectedContent, }) await reloadHistory(resolvedSessionId) diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx index e810c544..5c9e7fe0 100644 --- a/desktop/src/components/workspace/WorkspacePanel.test.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -120,22 +120,26 @@ vi.mock('../../api/sessions', () => ({ })) import { useSettingsStore } from '../../stores/settingsStore' +import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' import { WorkspacePanel } from './WorkspacePanel' describe('WorkspacePanel', () => { const workspaceInitialState = useWorkspacePanelStore.getInitialState() + const workspaceChatInitialState = useWorkspaceChatContextStore.getInitialState() const settingsInitialState = useSettingsStore.getInitialState() beforeEach(async () => { vi.clearAllMocks() await setWorkspaceState(workspaceInitialState) + useWorkspaceChatContextStore.setState(workspaceChatInitialState, true) await setSettingsState({ ...settingsInitialState, locale: 'en' }) }) afterEach(async () => { cleanup() await setWorkspaceState(workspaceInitialState) + useWorkspaceChatContextStore.setState(workspaceChatInitialState, true) await setSettingsState(settingsInitialState) vi.restoreAllMocks() }) @@ -792,6 +796,123 @@ describe('WorkspacePanel', () => { expect(view.queryByRole('tab', { name: /index\.css/i })).toBeNull() }) + it('adds a workspace file to the chat context from the file tree menu', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-add-file': { + isOpen: true, + activeView: 'all', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-add-file': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + treeBySessionPath: { + ...state.treeBySessionPath, + 'session-add-file': { + '': { + state: 'ok', + path: '', + entries: [{ name: 'App.tsx', path: 'src/App.tsx', isDirectory: false }], + }, + }, + }, + })) + + const view = await renderPanel('session-add-file') + + await act(() => { + fireEvent.contextMenu(view.getByRole('button', { name: /App\.tsx/i }), { + clientX: 260, + clientY: 80, + }) + }) + + await clickElement(view.getByRole('menuitem', { name: 'Add to chat' })) + + expect(useWorkspaceChatContextStore.getState().referencesBySession['session-add-file']).toMatchObject([ + { + kind: 'file', + path: 'src/App.tsx', + absolutePath: '/repo/src/App.tsx', + name: 'App.tsx', + }, + ]) + }) + + it('adds a line comment from a code preview to the chat context', async () => { + await setWorkspaceState((state) => ({ + ...state, + panelBySession: { + ...state.panelBySession, + 'session-line-comment': { + isOpen: true, + activeView: 'all', + }, + }, + statusBySession: { + ...state.statusBySession, + 'session-line-comment': { + state: 'ok', + workDir: '/repo', + repoName: 'repo', + branch: 'main', + isGitRepo: true, + changedFiles: [], + }, + }, + previewTabsBySession: { + ...state.previewTabsBySession, + 'session-line-comment': [{ + id: 'file:src/App.tsx', + path: 'src/App.tsx', + kind: 'file', + title: 'App.tsx', + language: 'tsx', + content: 'const title = "Todo"\nexport default title', + state: 'ok', + size: 42, + }], + }, + activePreviewTabIdBySession: { + ...state.activePreviewTabIdBySession, + 'session-line-comment': 'file:src/App.tsx', + }, + })) + + const view = await renderPanel('session-line-comment') + + await clickElement(view.getByRole('button', { name: 'Comment line 1' })) + const textarea = view.getByPlaceholderText('Describe what should change here...') + await act(() => { + fireEvent.change(textarea, { target: { value: 'Rename this title' } }) + }) + await clickElement(view.getByRole('button', { name: 'Add comment' })) + + expect(useWorkspaceChatContextStore.getState().referencesBySession['session-line-comment']).toMatchObject([ + { + kind: 'code-comment', + path: 'src/App.tsx', + absolutePath: '/repo/src/App.tsx', + name: 'App.tsx', + lineStart: 1, + lineEnd: 1, + note: 'Rename this title', + quote: 'const title = "Todo"', + }, + ]) + }) + 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 76fad69d..b65830ba 100644 --- a/desktop/src/components/workspace/WorkspacePanel.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.tsx @@ -14,6 +14,7 @@ import { type WorkspacePreviewKind, type WorkspacePreviewTab, } from '../../stores/workspacePanelStore' +import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore' import { MarkdownRenderer } from '../markdown/MarkdownRenderer' import { getFileExtension, @@ -38,6 +39,7 @@ type TreeNodeProps = { filterQuery: string onToggle: (path: string) => void onOpenFile: (path: string) => void + onFileContextMenu: (event: MouseEvent, path: string) => void activePath: string | null } @@ -128,6 +130,11 @@ function getFileBadgeMeta(name: string) { } } +function resolveWorkspaceAttachmentPath(workDir: string | undefined, filePath: string) { + if (!workDir || filePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(filePath)) return filePath + return `${workDir.replace(/[\\/]+$/, '')}/${filePath.replace(/^[/\\]+/, '')}` +} + function isMarkdownPreview(tab: WorkspacePreviewTab) { if (tab.kind !== 'file') return false const language = (tab.language ?? '').toLowerCase() @@ -296,12 +303,30 @@ function FileStatusBadge({ status }: { status: WorkspaceFileStatus }) { ) } -function CodeSurface({ value, language }: { value: string; language: string }) { +function CodeSurface({ + value, + language, + onAddLineComment, +}: { + value: string + language: string + onAddLineComment: (line: number, note: string, quote: string) => void +}) { const t = useTranslation() + const [commentLine, setCommentLine] = useState(null) + const [commentDraft, setCommentDraft] = useState('') const lines = value.split('\n') const visibleLines = lines.slice(0, WORKSPACE_PREVIEW_LINE_LIMIT) const visibleCode = visibleLines.join('\n') const hiddenLineCount = Math.max(0, lines.length - visibleLines.length) + const activeQuote = commentLine ? visibleLines[commentLine - 1] ?? '' : '' + + const submitLineComment = () => { + if (!commentLine || !commentDraft.trim()) return + onAddLineComment(commentLine, commentDraft.trim(), activeQuote) + setCommentLine(null) + setCommentDraft('') + } return (
@@ -320,21 +345,73 @@ function CodeSurface({ value, language }: { value: string; language: string }) { > {tokens.map((line, index) => { const { key: lineKey, ...lineProps } = getLineProps({ line, key: index }) + const lineNumber = index + 1 return ( -
- - {index + 1} - - - {line.length === 1 && line[0]?.empty ? ' ' : line.map((token, tokenIndex) => { - const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex }) - return - })} - +
+
+ + + {line.length === 1 && line[0]?.empty ? ' ' : line.map((token, tokenIndex) => { + const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex }) + return + })} + +
+ {commentLine === lineNumber && ( +
+