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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-25 18:13:09 +08:00
parent 82a47d051a
commit 1b2bded1a2
7 changed files with 339 additions and 10 deletions

View File

@ -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(<ChatInput compact />)
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',

View File

@ -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)

View File

@ -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

View File

@ -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<WorkspaceFileStatus, { label: string; className: string }> = {
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({
<button
type="button"
onClick={() => onOpenFile(entry.path)}
onContextMenu={(event) => onFileContextMenu(event, entry.path)}
onContextMenu={(event) => onFileContextMenu(event, entry.path, false)}
className={`group mx-2 flex h-8 w-[calc(100%-16px)] items-center gap-2 rounded-[7px] pr-2 text-left transition-colors ${
isActive
? 'bg-[var(--color-surface-selected)] shadow-[inset_0_0_0_1.5px_var(--color-border-focus)]'
@ -819,6 +835,7 @@ function TreeNode({
<button
type="button"
onClick={() => onToggle(entry.path)}
onContextMenu={(event) => onFileContextMenu(event, entry.path, true)}
aria-expanded={isVisuallyExpanded}
className="group mx-2 flex h-8 w-[calc(100%-16px)] items-center gap-2 rounded-[7px] pr-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
style={{ paddingLeft: indent }}
@ -898,7 +915,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
const [filterQuery, setFilterQuery] = useState('')
const [isViewMenuOpen, setIsViewMenuOpen] = useState(false)
const [previewTabContextMenu, setPreviewTabContextMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
const [fileContextMenu, setFileContextMenu] = useState<{ path: string; x: number; y: number } | null>(null)
const [fileContextMenu, setFileContextMenu] = useState<FileContextMenuState | null>(null)
const width = useWorkspacePanelStore((state) => state.width)
const isOpen = useWorkspacePanelStore((state) => state.isPanelOpen(sessionId))
const activeView = useWorkspacePanelStore((state) => state.getActiveView(sessionId))
@ -924,6 +941,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
const closePreviewTabs = useWorkspacePanelStore((state) => state.closePreviewTabs)
const closePanel = useWorkspacePanelStore((state) => state.closePanel)
const addWorkspaceReference = useWorkspaceChatContextStore((state) => state.addReference)
const queueComposerInsertion = useChatStore((state) => state.queueComposerInsertion)
const chatState = useChatStore((state) => state.sessions[sessionId]?.chatState ?? 'idle')
const refreshLifecycleRef = useRef({
sessionId,
@ -1015,12 +1033,26 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
void openPreview(sessionId, path, 'file')
}
const addFileToChat = (path: string) => {
const addWorkspacePathToChat = (path: string, isDirectory = false) => {
addWorkspaceReference(sessionId, {
kind: 'file',
path,
absolutePath: resolveWorkspaceAttachmentPath(status?.workDir, path),
name: path.split('/').pop() || path,
name: getWorkspaceReferenceName(path, isDirectory),
isDirectory,
})
}
const citeWorkspacePathInMessage = (path: string, isDirectory = false) => {
queueComposerInsertion(sessionId, {
text: formatInlineWorkspaceReference(path),
reference: {
kind: 'file',
path,
absolutePath: resolveWorkspaceAttachmentPath(status?.workDir, path),
name: getWorkspaceReferenceName(path, isDirectory),
isDirectory,
},
})
}
@ -1061,11 +1093,11 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
setPreviewTabContextMenu({ tabId, x: event.clientX, y: event.clientY })
}
const handleFileContextMenu = (event: MouseEvent, path: string) => {
const handleFileContextMenu = (event: MouseEvent, path: string, isDirectory = false) => {
event.preventDefault()
event.stopPropagation()
setPreviewTabContextMenu(null)
setFileContextMenu({ path, x: event.clientX, y: event.clientY })
setFileContextMenu({ path, isDirectory, x: event.clientX, y: event.clientY })
}
const handleClosePreviewTabs = (scope: WorkspacePreviewCloseScope) => {
@ -1213,7 +1245,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
))}
<button
type="button"
onClick={() => addFileToChat(activePreviewTab.path)}
onClick={() => addWorkspacePathToChat(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)]"
>
<span aria-hidden="true" className="material-symbols-outlined text-[14px]">person_add</span>
@ -1461,7 +1493,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
type="button"
role="menuitem"
onClick={() => {
addFileToChat(fileContextMenu.path)
addWorkspacePathToChat(fileContextMenu.path, fileContextMenu.isDirectory)
setFileContextMenu(null)
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
@ -1469,6 +1501,18 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
<span aria-hidden="true" className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">person_add</span>
<span>{t('workspace.addToChat')}</span>
</button>
<button
type="button"
role="menuitem"
onClick={() => {
citeWorkspacePathInMessage(fileContextMenu.path, fileContextMenu.isDirectory)
setFileContextMenu(null)
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
>
<span aria-hidden="true" className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">chat_bubble</span>
<span>{t('workspace.citeInMessage')}</span>
</button>
<button
type="button"
role="menuitem"

View File

@ -123,6 +123,7 @@ export const en = {
'workspace.collapsePreview': 'Collapse preview',
'workspace.addToChat': 'Add to chat',
'workspace.addSelectionToChat': 'Add to chat',
'workspace.citeInMessage': 'Cite in message',
'workspace.copyPath': 'Copy path',
'workspace.pathCopied': 'Path copied.',
'workspace.localComment': 'Local comment',

View File

@ -125,6 +125,7 @@ export const zh: Record<TranslationKey, string> = {
'workspace.collapsePreview': '收起预览',
'workspace.addToChat': '添加到聊天',
'workspace.addSelectionToChat': '添加到对话',
'workspace.citeInMessage': '引用到对话',
'workspace.copyPath': '复制路径',
'workspace.pathCopied': '路径已复制。',
'workspace.localComment': '本地评论',

View File

@ -41,6 +41,18 @@ export type ComposerDraftState = {
attachments: ComposerAttachment[]
}
export type ComposerReferenceInsertion = {
text: string
reference?: {
kind: 'file'
path: string
absolutePath?: string
name: string
isDirectory?: boolean
}
nonce: number
}
export type PerSessionState = {
messages: UIMessage[]
chatState: ChatState
@ -75,6 +87,7 @@ export type PerSessionState = {
attachments?: UIAttachment[]
nonce: number
} | null
composerInsertion?: ComposerReferenceInsertion | null
composerDraft?: ComposerDraftState | null
}
@ -99,6 +112,7 @@ const DEFAULT_SESSION_STATE: PerSessionState = {
activeGoal: null,
elapsedTimer: null,
composerPrefill: null,
composerInsertion: null,
composerDraft: null,
}
@ -141,6 +155,11 @@ type ChatStore = {
sessionId: string,
prefill: { text: string; attachments?: UIAttachment[] },
) => void
queueComposerInsertion: (
sessionId: string,
insertion: Omit<ComposerReferenceInsertion, 'nonce'>,
) => void
clearComposerInsertion: (sessionId: string, nonce?: number) => void
setComposerDraft: (sessionId: string, draft: ComposerDraftState) => void
clearComposerDraft: (sessionId: string) => void
clearMessages: (sessionId: string) => void
@ -1133,6 +1152,26 @@ export const useChatStore = create<ChatStore>((set, get) => ({
}))
},
queueComposerInsertion: (sessionId, insertion) => {
set((state) => ({
sessions: updateSessionIn(state.sessions, sessionId, () => ({
composerInsertion: {
...insertion,
nonce: Date.now(),
},
})),
}))
},
clearComposerInsertion: (sessionId, nonce) => {
set((state) => ({
sessions: updateSessionIn(state.sessions, sessionId, (session) => {
if (nonce !== undefined && session.composerInsertion?.nonce !== nonce) return {}
return { composerInsertion: null }
}),
}))
},
setComposerDraft: (sessionId, draft) => {
set((state) => {
const session = state.sessions[sessionId] ?? createDefaultSessionState()