From 1b2bded1a2df7cd25994927be3c769a9220b826d 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: Mon, 25 May 2026 18:13:09 +0800 Subject: [PATCH] feat: streamline workspace references from the file tree (#590, #597) The workspace panel already had the attachment model for both files and directories, but directory rows lacked the same right-click path and there was no low-risk handoff from the panel into the active composer text. This keeps context attachment as the stable source of file content while adding a small composer insertion event for inline citations. Constraint: Keep the existing attachment pipeline unchanged so file and directory contents still flow through the proven workspace reference path Rejected: Build a rich inline chip editor immediately | larger composer and history-synchronization surface than these issues require Confidence: high Scope-risk: moderate Directive: Do not replace workspace attachments with inline text-only mentions without rechecking model-content generation and history restore behavior Tested: cd desktop && bunx vitest run src/components/workspace/WorkspacePanel.test.tsx Tested: cd desktop && bunx vitest run src/components/chat/ChatInput.test.tsx Tested: bun run check:desktop Not-tested: Live provider response quality with inline citation wording --- .../src/components/chat/ChatInput.test.tsx | 63 +++++++++ desktop/src/components/chat/ChatInput.tsx | 50 ++++++- .../workspace/WorkspacePanel.test.tsx | 133 ++++++++++++++++++ .../components/workspace/WorkspacePanel.tsx | 62 ++++++-- desktop/src/i18n/locales/en.ts | 1 + desktop/src/i18n/locales/zh.ts | 1 + desktop/src/stores/chatStore.ts | 39 +++++ 7 files changed, 339 insertions(+), 10 deletions(-) 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({ +