diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx
index 6b1c3d63..8a58957e 100644
--- a/desktop/src/components/chat/ChatInput.test.tsx
+++ b/desktop/src/components/chat/ChatInput.test.tsx
@@ -594,6 +594,69 @@ describe('ChatInput file mentions', () => {
})
})
+ it('inserts queued inline workspace citations at the current cursor and keeps file context attached', async () => {
+ render()
+
+ const input = screen.getByRole('textbox') as HTMLTextAreaElement
+ fireEvent.change(input, {
+ target: {
+ value: '请看实现',
+ selectionStart: 2,
+ selectionEnd: 2,
+ },
+ })
+ input.setSelectionRange(2, 2)
+
+ act(() => {
+ useChatStore.getState().queueComposerInsertion(sessionId, {
+ text: '@"src/App.tsx"',
+ reference: {
+ kind: 'file',
+ path: 'src/App.tsx',
+ absolutePath: '/repo/src/App.tsx',
+ name: 'App.tsx',
+ },
+ })
+ })
+
+ await waitFor(() => {
+ expect(input.value).toBe('请看 @"src/App.tsx" 实现')
+ })
+ expect(screen.getByText('App.tsx')).toBeInTheDocument()
+ expect(useWorkspaceChatContextStore.getState().referencesBySession[sessionId]).toMatchObject([
+ {
+ kind: 'file',
+ path: 'src/App.tsx',
+ absolutePath: '/repo/src/App.tsx',
+ name: 'App.tsx',
+ },
+ ])
+
+ fireEvent.keyDown(input, { key: 'Enter' })
+
+ expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, {
+ type: 'user_message',
+ content: '请看 @"src/App.tsx" 实现',
+ attachments: [{
+ type: 'file',
+ name: 'App.tsx',
+ path: '/repo/src/App.tsx',
+ isDirectory: undefined,
+ lineStart: undefined,
+ lineEnd: undefined,
+ note: undefined,
+ quote: undefined,
+ }],
+ })
+ const messages = useChatStore.getState().sessions[sessionId]?.messages ?? []
+ expect(messages[messages.length - 1]).toMatchObject({
+ type: 'user_text',
+ content: '请看 @"src/App.tsx" 实现',
+ modelContent: '@"/repo/src/App.tsx" 请看 @"src/App.tsx" 实现',
+ attachments: [{ name: 'App.tsx', path: 'src/App.tsx' }],
+ })
+ })
+
it('turns a selected @ directory into a workspace chip and model path reference', async () => {
mocks.search.mockResolvedValueOnce({
currentPath: '/repo',
diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx
index 48bc7aae..024a5c6e 100644
--- a/desktop/src/components/chat/ChatInput.tsx
+++ b/desktop/src/components/chat/ChatInput.tsx
@@ -65,6 +65,21 @@ function workspaceReferenceToAttachment(reference: WorkspaceChatReference): Atta
}
}
+function insertComposerTokenAtRange(value: string, start: number, end: number, token: string) {
+ const boundedStart = Math.max(0, Math.min(start, value.length))
+ const boundedEnd = Math.max(boundedStart, Math.min(end, value.length))
+ const before = value.slice(0, boundedStart)
+ const after = value.slice(boundedEnd)
+ const leadingSpace = before.length > 0 && !/\s$/.test(before) ? ' ' : ''
+ const trailingSpace = after.length > 0 && !/^\s/.test(after) ? ' ' : ''
+ const insertion = `${leadingSpace}${token}${trailingSpace}`
+
+ return {
+ value: `${before}${insertion}${after}`,
+ cursorPos: before.length + insertion.length,
+ }
+}
+
export function ChatInput({ variant = 'default', compact = false }: ChatInputProps) {
const t = useTranslation()
const isMobileComposer = useMobileViewport() && !isTauriRuntime()
@@ -105,12 +120,13 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
return next
})
}, [])
- const { sendMessage, stopGeneration } = useChatStore()
+ const { sendMessage, stopGeneration, clearComposerInsertion } = useChatStore()
const activeTabId = useTabStore((s) => s.activeTabId)
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
const chatState = sessionState?.chatState ?? 'idle'
const slashCommands = sessionState?.slashCommands ?? []
const composerPrefill = sessionState?.composerPrefill ?? null
+ const composerInsertion = sessionState?.composerInsertion ?? null
const runtimeSelection = useSessionRuntimeStore((state) =>
activeTabId ? state.selections[activeTabId] : undefined,
)
@@ -242,6 +258,38 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
})
}, [composerPrefill, setComposerAttachments, setComposerInput])
+ useEffect(() => {
+ if (!composerInsertion || !activeTabId || isMemberSession) return
+
+ const el = textareaRef.current
+ const currentInput = inputRef.current
+ const start = el?.selectionStart ?? currentInput.length
+ const end = el?.selectionEnd ?? start
+ const next = insertComposerTokenAtRange(currentInput, start, end, composerInsertion.text)
+
+ if (composerInsertion.reference) {
+ addWorkspaceReference(activeTabId, composerInsertion.reference)
+ }
+ setComposerInput(next.value)
+ setFileSearchOpen(false)
+ setSlashMenuOpen(false)
+ setAtFilter('')
+ setAtCursorPos(-1)
+ clearComposerInsertion(activeTabId, composerInsertion.nonce)
+
+ requestAnimationFrame(() => {
+ textareaRef.current?.focus()
+ textareaRef.current?.setSelectionRange(next.cursorPos, next.cursorPos)
+ })
+ }, [
+ activeTabId,
+ addWorkspaceReference,
+ clearComposerInsertion,
+ composerInsertion,
+ isMemberSession,
+ setComposerInput,
+ ])
+
const refreshGitInfo = useCallback(() => {
if (!activeTabId) {
setGitInfo(null)
diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx
index b2d36688..44a136eb 100644
--- a/desktop/src/components/workspace/WorkspacePanel.test.tsx
+++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx
@@ -1195,6 +1195,139 @@ describe('WorkspacePanel', () => {
])
})
+ it('adds a workspace directory to the chat context from the file tree menu', async () => {
+ await setWorkspaceState((state) => ({
+ ...state,
+ panelBySession: {
+ ...state.panelBySession,
+ 'session-add-directory': {
+ isOpen: true,
+ activeView: 'all',
+ },
+ },
+ statusBySession: {
+ ...state.statusBySession,
+ 'session-add-directory': {
+ state: 'ok',
+ workDir: '/repo',
+ repoName: 'repo',
+ branch: 'main',
+ isGitRepo: true,
+ changedFiles: [],
+ },
+ },
+ treeBySessionPath: {
+ ...state.treeBySessionPath,
+ 'session-add-directory': {
+ '': {
+ state: 'ok',
+ path: '',
+ entries: [{ name: 'src', path: 'src', isDirectory: true }],
+ },
+ },
+ },
+ }))
+
+ const view = await renderPanel('session-add-directory')
+
+ await act(() => {
+ fireEvent.contextMenu(view.getByRole('button', { name: /src/i }), {
+ clientX: 260,
+ clientY: 80,
+ })
+ })
+
+ await clickElement(view.getByRole('menuitem', { name: 'Add to chat' }))
+
+ expect(useWorkspaceChatContextStore.getState().referencesBySession['session-add-directory']).toMatchObject([
+ {
+ kind: 'file',
+ path: 'src',
+ absolutePath: '/repo/src',
+ name: 'src/',
+ isDirectory: true,
+ },
+ ])
+ })
+
+ it('queues an inline workspace citation from the file tree menu', async () => {
+ useChatStore.setState({
+ sessions: {
+ 'session-cite-file': {
+ messages: [],
+ chatState: 'idle',
+ connectionState: 'connected',
+ streamingText: '',
+ streamingToolInput: '',
+ activeToolUseId: null,
+ activeToolName: null,
+ activeThinkingId: null,
+ pendingPermission: null,
+ pendingComputerUsePermission: null,
+ tokenUsage: { input_tokens: 0, output_tokens: 0 },
+ elapsedSeconds: 0,
+ statusVerb: '',
+ slashCommands: [],
+ agentTaskNotifications: {},
+ elapsedTimer: null,
+ },
+ },
+ })
+ await setWorkspaceState((state) => ({
+ ...state,
+ panelBySession: {
+ ...state.panelBySession,
+ 'session-cite-file': {
+ isOpen: true,
+ activeView: 'all',
+ },
+ },
+ statusBySession: {
+ ...state.statusBySession,
+ 'session-cite-file': {
+ state: 'ok',
+ workDir: '/repo',
+ repoName: 'repo',
+ branch: 'main',
+ isGitRepo: true,
+ changedFiles: [],
+ },
+ },
+ treeBySessionPath: {
+ ...state.treeBySessionPath,
+ 'session-cite-file': {
+ '': {
+ state: 'ok',
+ path: '',
+ entries: [{ name: 'App.tsx', path: 'src/App.tsx', isDirectory: false }],
+ },
+ },
+ },
+ }))
+
+ const view = await renderPanel('session-cite-file')
+
+ await act(() => {
+ fireEvent.contextMenu(view.getByRole('button', { name: /App\.tsx/i }), {
+ clientX: 260,
+ clientY: 80,
+ })
+ })
+
+ await clickElement(view.getByRole('menuitem', { name: 'Cite in message' }))
+
+ expect(useChatStore.getState().sessions['session-cite-file']?.composerInsertion).toMatchObject({
+ text: '@"src/App.tsx"',
+ reference: {
+ kind: 'file',
+ path: 'src/App.tsx',
+ absolutePath: '/repo/src/App.tsx',
+ name: 'App.tsx',
+ isDirectory: false,
+ },
+ })
+ })
+
it('copies file paths from the file tree menu with the legacy clipboard fallback', async () => {
const originalClipboard = navigator.clipboard
const originalExecCommand = document.execCommand
diff --git a/desktop/src/components/workspace/WorkspacePanel.tsx b/desktop/src/components/workspace/WorkspacePanel.tsx
index 3bd6c2b6..7c84412d 100644
--- a/desktop/src/components/workspace/WorkspacePanel.tsx
+++ b/desktop/src/components/workspace/WorkspacePanel.tsx
@@ -44,10 +44,17 @@ type TreeNodeProps = {
filterQuery: string
onToggle: (path: string) => void
onOpenFile: (path: string) => void
- onFileContextMenu: (event: MouseEvent, path: string) => void
+ onFileContextMenu: (event: MouseEvent, path: string, isDirectory: boolean) => void
activePath: string | null
}
+type FileContextMenuState = {
+ path: string
+ isDirectory: boolean
+ x: number
+ y: number
+}
+
const FILE_STATUS_META: Record = {
modified: {
label: 'M',
@@ -143,6 +150,15 @@ function resolveWorkspaceAttachmentPath(workDir: string | undefined, filePath: s
return `${workDir.replace(/[\\/]+$/, '')}/${filePath.replace(/^[/\\]+/, '')}`
}
+function getWorkspaceReferenceName(path: string, isDirectory = false) {
+ const name = path.split('/').filter(Boolean).pop() || path
+ return isDirectory && !name.endsWith('/') ? `${name}/` : name
+}
+
+function formatInlineWorkspaceReference(path: string) {
+ return `@${JSON.stringify(path)}`
+}
+
function isMarkdownPreview(tab: WorkspacePreviewTab) {
if (tab.kind !== 'file') return false
const language = (tab.language ?? '').toLowerCase()
@@ -800,7 +816,7 @@ function TreeNode({