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 && (
+
+
+
+
+ chat_bubble
+ {t('workspace.localComment')}
+
+ {t('workspace.commentLineTarget', { line: lineNumber })}
+
+
+
+
+ )}
)
})}
@@ -393,14 +470,17 @@ function ImagePreview({ tab }: { tab: WorkspacePreviewTab }) {
function ChangedFileRow({
file,
onClick,
+ onContextMenu,
}: {
file: WorkspaceChangedFile
onClick: () => void
+ onContextMenu: (event: MouseEvent, path: string) => void
}) {
return (
@@ -746,6 +871,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
void toggleTreeNode(sessionId, path)
}}
onOpenFile={handleOpenFile}
+ onFileContextMenu={handleFileContextMenu}
activePath={activeTreePath}
/>
))}
@@ -777,7 +903,15 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
))}
-
+ addFileToChat(activePreviewTab.path)}
+ className="ml-auto inline-flex h-6 shrink-0 items-center gap-1 rounded-[6px] px-1.5 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
+ >
+ person_add
+ {t('workspace.addToChat')}
+
+
{kindLabel}
@@ -797,6 +931,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
addLineCommentToChat(activePreviewTab.path, line, note, quote)}
/>
) : (
+
+ {fileContextMenu && (
+