mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
feat: 让工作区文件引用成为可回滚的聊天上下文
桌面端的工作区文件面板现在可以把文件或局部评论加入聊天,发送给 CLI 的内容保持为原生 @文件路径引用,聊天界面则保留用户可读的文件 chip。为了让本轮变更卡片和 checkpoint 回滚继续可靠,UI 消息会同时保存用于展示的内容和实际发给模型的 modelContent;历史转录里的 @文件路径也会在重载时还原成附件 chip。 Constraint: CLI 侧最稳定的文件上下文入口仍是原生 @文件路径 Constraint: 聊天 UI 不能把绝对路径裸露成用户消息正文 Rejected: 只发送隐藏提示文本 | CLI 对 @文件路径已有稳定读取行为 Rejected: 只依赖前端展示内容做回滚校验 | 会和真实转录内容不一致 Confidence: high Scope-risk: moderate Directive: 修改附件展示或回滚校验时必须同时验证 modelContent 与 UI content 的分离 Tested: cd desktop && bun run test -- src/stores/chatStore.test.ts src/components/chat/MessageList.test.tsx src/stores/workspaceChatContextStore.test.ts src/components/workspace/WorkspacePanel.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: SKIP_INSTALL=1 ./desktop/scripts/build-macos-arm64.sh Tested: codesign --verify --deep --strict --verbose=2 desktop/build-artifacts/macos-arm64/Claude\ Code\ Haha.app Tested: hdiutil verify desktop/build-artifacts/macos-arm64/Claude\ Code\ Haha_0.1.8_aarch64.dmg Tested: Computer Use macOS run with /private/tmp/cc-haha-computer-use-e2e verified file chip, @path model input, model sentinel response, workspace preview interactions Not-tested: Deep unloaded-folder fuzzy search in all-files view
This commit is contained in:
parent
ba8c6b8828
commit
80aca8c176
@ -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 (
|
||||
<>
|
||||
<div className={isComposer ? 'flex flex-wrap items-center gap-2' : 'grid grid-cols-1 gap-2 sm:grid-cols-2'}>
|
||||
<div className={isComposer ? 'flex flex-wrap items-center gap-2' : 'flex flex-wrap justify-end gap-2'}>
|
||||
{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 (
|
||||
<div
|
||||
key={attachment.id || `${attachment.name}-${index}`}
|
||||
className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2 text-xs text-[var(--color-text-secondary)]"
|
||||
className={[
|
||||
'group/file inline-flex max-w-full min-w-0 items-center gap-2 rounded-full border border-[var(--color-border)]',
|
||||
'bg-[var(--color-surface-container-low)] text-[var(--color-text-secondary)] shadow-[0_1px_2px_rgba(0,0,0,0.04)]',
|
||||
isComposer ? 'h-9 px-3' : 'h-9 px-3',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">attach_file</span>
|
||||
<span className="max-w-[220px] truncate">{attachment.name}</span>
|
||||
<span className="material-symbols-outlined shrink-0 text-[17px] text-[var(--color-text-tertiary)]">
|
||||
{attachment.lineStart ? 'chat_bubble' : 'description'}
|
||||
</span>
|
||||
<span className="min-w-0 max-w-[220px] truncate text-[13px] font-medium leading-none text-[var(--color-text-primary)]">
|
||||
{attachment.name}
|
||||
{attachment.lineStart
|
||||
? `:L${attachment.lineStart}${attachment.lineEnd && attachment.lineEnd !== attachment.lineStart ? `-L${attachment.lineEnd}` : ''}`
|
||||
: ''}
|
||||
</span>
|
||||
{onRemove && attachment.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(attachment.id!)}
|
||||
className="ml-1 text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-error)]"
|
||||
className="ml-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
|
||||
aria-label={`Remove ${attachment.name}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
<span className="material-symbols-outlined text-[17px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -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<GitInfo | null>(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
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachments.length > 0 && (
|
||||
{composerAttachments.length > 0 && (
|
||||
isHeroComposer ? (
|
||||
<AttachmentGallery attachments={attachments} variant="composer" onRemove={removeAttachment} />
|
||||
<AttachmentGallery attachments={composerAttachments} variant="composer" onRemove={removeAttachment} />
|
||||
) : (
|
||||
<div className="px-3 pt-3">
|
||||
<AttachmentGallery attachments={attachments} variant="composer" onRemove={removeAttachment} />
|
||||
<AttachmentGallery attachments={composerAttachments} variant="composer" onRemove={removeAttachment} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@ -38,6 +38,7 @@ type RewindTurnTarget = {
|
||||
messageId: string
|
||||
userMessageIndex: number
|
||||
content: string
|
||||
expectedContent: string
|
||||
attachments?: Extract<UIMessage, { type: 'user_text' }>['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)
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
@ -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<number | null>(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 (
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]">
|
||||
@ -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 (
|
||||
<div
|
||||
key={String(lineKey)}
|
||||
{...lineProps}
|
||||
className="grid grid-cols-[48px_minmax(0,1fr)] gap-3 px-3 hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
<span className="select-none text-right text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="whitespace-pre pr-6">
|
||||
{line.length === 1 && line[0]?.empty ? ' ' : line.map((token, tokenIndex) => {
|
||||
const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex })
|
||||
return <span key={String(tokenKey)} {...tokenProps} />
|
||||
})}
|
||||
</span>
|
||||
<div key={String(lineKey)}>
|
||||
<div
|
||||
{...lineProps}
|
||||
className="group grid grid-cols-[48px_minmax(0,1fr)] gap-3 px-3 hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('workspace.commentLine', { line: lineNumber })}
|
||||
onClick={() => {
|
||||
setCommentLine(lineNumber)
|
||||
setCommentDraft('')
|
||||
}}
|
||||
className="select-none text-right text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-brand)] focus-visible:outline-none focus-visible:text-[var(--color-brand)]"
|
||||
>
|
||||
{lineNumber}
|
||||
</button>
|
||||
<span className="whitespace-pre pr-6">
|
||||
{line.length === 1 && line[0]?.empty ? ' ' : line.map((token, tokenIndex) => {
|
||||
const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex })
|
||||
return <span key={String(tokenKey)} {...tokenProps} />
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{commentLine === lineNumber && (
|
||||
<div className="grid grid-cols-[48px_minmax(0,720px)] gap-3 bg-[var(--color-brand)]/10 px-3 py-2">
|
||||
<span aria-hidden="true" />
|
||||
<div className="rounded-[10px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-sm">
|
||||
<div className="flex items-center gap-2 border-b border-[var(--color-border)] px-3 py-2">
|
||||
<span className="material-symbols-outlined text-[15px] text-[var(--color-text-tertiary)]">chat_bubble</span>
|
||||
<span className="text-[12px] font-semibold text-[var(--color-text-primary)]">{t('workspace.localComment')}</span>
|
||||
<span className="ml-auto text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('workspace.commentLineTarget', { line: lineNumber })}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={commentDraft}
|
||||
onChange={(event) => setCommentDraft(event.target.value)}
|
||||
autoFocus
|
||||
rows={3}
|
||||
placeholder={t('workspace.commentPlaceholder')}
|
||||
className="block w-full resize-none bg-transparent px-3 py-3 text-[13px] leading-6 text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
|
||||
/>
|
||||
<div className="flex justify-end gap-2 px-3 pb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCommentLine(null)
|
||||
setCommentDraft('')
|
||||
}}
|
||||
className="rounded-[7px] px-2.5 py-1 text-[12px] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitLineComment}
|
||||
disabled={!commentDraft.trim()}
|
||||
className="rounded-[7px] bg-[var(--color-text-primary)] px-2.5 py-1 text-[12px] font-medium text-[var(--color-surface)] disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{t('workspace.addCommentToChat')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onContextMenu={(event) => onContextMenu(event, file.path)}
|
||||
className="mx-2 flex w-[calc(100%-16px)] items-center gap-3 rounded-[7px] px-2 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
<FileStatusBadge status={file.status} />
|
||||
@ -431,6 +511,7 @@ function TreeNode({
|
||||
filterQuery,
|
||||
onToggle,
|
||||
onOpenFile,
|
||||
onFileContextMenu,
|
||||
activePath,
|
||||
}: TreeNodeProps) {
|
||||
const t = useTranslation()
|
||||
@ -447,6 +528,7 @@ function TreeNode({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenFile(entry.path)}
|
||||
onContextMenu={(event) => onFileContextMenu(event, entry.path)}
|
||||
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)]'
|
||||
@ -528,6 +610,7 @@ function TreeNode({
|
||||
filterQuery={filterQuery}
|
||||
onToggle={onToggle}
|
||||
onOpenFile={onOpenFile}
|
||||
onFileContextMenu={onFileContextMenu}
|
||||
activePath={activePath}
|
||||
/>
|
||||
))}
|
||||
@ -542,6 +625,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 width = useWorkspacePanelStore((state) => state.width)
|
||||
const isOpen = useWorkspacePanelStore((state) => state.isPanelOpen(sessionId))
|
||||
const activeView = useWorkspacePanelStore((state) => state.getActiveView(sessionId))
|
||||
@ -566,6 +650,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
const closePreview = useWorkspacePanelStore((state) => state.closePreview)
|
||||
const closePreviewTabs = useWorkspacePanelStore((state) => state.closePreviewTabs)
|
||||
const closePanel = useWorkspacePanelStore((state) => state.closePanel)
|
||||
const addWorkspaceReference = useWorkspaceChatContextStore((state) => state.addReference)
|
||||
|
||||
const rootTree = treeByPath['']
|
||||
const rootTreeKey = makeTreeStateKey(sessionId, '')
|
||||
@ -607,11 +692,14 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
}, [activeView, isOpen, loadTree, rootTree, rootTreeError, rootTreeLoading, sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewTabContextMenu) return
|
||||
const close = () => setPreviewTabContextMenu(null)
|
||||
if (!previewTabContextMenu && !fileContextMenu) return
|
||||
const close = () => {
|
||||
setPreviewTabContextMenu(null)
|
||||
setFileContextMenu(null)
|
||||
}
|
||||
document.addEventListener('click', close)
|
||||
return () => document.removeEventListener('click', close)
|
||||
}, [previewTabContextMenu])
|
||||
}, [fileContextMenu, previewTabContextMenu])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
@ -635,6 +723,28 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
void openPreview(sessionId, path, 'file')
|
||||
}
|
||||
|
||||
const addFileToChat = (path: string) => {
|
||||
addWorkspaceReference(sessionId, {
|
||||
kind: 'file',
|
||||
path,
|
||||
absolutePath: resolveWorkspaceAttachmentPath(status?.workDir, path),
|
||||
name: path.split('/').pop() || path,
|
||||
})
|
||||
}
|
||||
|
||||
const addLineCommentToChat = (path: string, line: number, note: string, quote: string) => {
|
||||
addWorkspaceReference(sessionId, {
|
||||
kind: 'code-comment',
|
||||
path,
|
||||
absolutePath: resolveWorkspaceAttachmentPath(status?.workDir, path),
|
||||
name: path.split('/').pop() || path,
|
||||
lineStart: line,
|
||||
lineEnd: line,
|
||||
note,
|
||||
quote,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSetActiveView = (view: 'changed' | 'all') => {
|
||||
setActiveView(sessionId, view)
|
||||
setIsViewMenuOpen(false)
|
||||
@ -643,15 +753,29 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
const handlePreviewTabContextMenu = (event: MouseEvent, tabId: string) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setFileContextMenu(null)
|
||||
setPreviewTabContextMenu({ tabId, x: event.clientX, y: event.clientY })
|
||||
}
|
||||
|
||||
const handleFileContextMenu = (event: MouseEvent, path: string) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setPreviewTabContextMenu(null)
|
||||
setFileContextMenu({ path, x: event.clientX, y: event.clientY })
|
||||
}
|
||||
|
||||
const handleClosePreviewTabs = (scope: WorkspacePreviewCloseScope) => {
|
||||
if (!previewTabContextMenu) return
|
||||
closePreviewTabs(sessionId, previewTabContextMenu.tabId, scope)
|
||||
setPreviewTabContextMenu(null)
|
||||
}
|
||||
|
||||
const copyWorkspacePath = (path: string) => {
|
||||
const resolvedPath = resolveWorkspaceAttachmentPath(status?.workDir, path)
|
||||
void navigator.clipboard?.writeText(resolvedPath)
|
||||
setFileContextMenu(null)
|
||||
}
|
||||
|
||||
const renderChangedView = () => {
|
||||
if (statusLoading && !status) {
|
||||
return <PanelMessage icon="progress_activity" message={t('common.loading')} />
|
||||
@ -694,6 +818,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
key={`${file.path}:${file.status}:${file.oldPath ?? ''}`}
|
||||
file={file}
|
||||
onClick={() => handleOpenDiff(file.path)}
|
||||
onContextMenu={handleFileContextMenu}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -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) {
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="ml-auto shrink-0 rounded-[5px] border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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)]"
|
||||
>
|
||||
<span aria-hidden="true" className="material-symbols-outlined text-[14px]">person_add</span>
|
||||
<span>{t('workspace.addToChat')}</span>
|
||||
</button>
|
||||
<span className="shrink-0 rounded-[5px] border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]">
|
||||
{kindLabel}
|
||||
</span>
|
||||
</div>
|
||||
@ -797,6 +931,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
<CodeSurface
|
||||
value={activePreviewTab.content ?? ''}
|
||||
language={activePreviewTab.language ?? 'text'}
|
||||
onAddLineComment={(line, note, quote) => addLineCommentToChat(activePreviewTab.path, line, note, quote)}
|
||||
/>
|
||||
) : (
|
||||
<PanelMessage
|
||||
@ -1002,6 +1137,37 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
|
||||
{activeView === 'changed' ? renderChangedView() : renderAllFilesView()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fileContextMenu && (
|
||||
<div
|
||||
role="menu"
|
||||
className="fixed z-50 min-w-[156px] rounded-[10px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 text-[12px] shadow-[var(--shadow-dropdown)]"
|
||||
style={{ left: fileContextMenu.x, top: fileContextMenu.y }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
addFileToChat(fileContextMenu.path)
|
||||
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)]">person_add</span>
|
||||
<span>{t('workspace.addToChat')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => copyWorkspacePath(fileContextMenu.path)}
|
||||
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)]">content_copy</span>
|
||||
<span>{t('workspace.copyPath')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@ -68,6 +68,13 @@ export const en = {
|
||||
'workspace.previewState.missing': 'File not found.',
|
||||
'workspace.imagePreviewUnavailable': 'Image preview is unavailable.',
|
||||
'workspace.previewLineLimit': 'Showing first {count} lines. Open in your editor for the full file.',
|
||||
'workspace.addToChat': 'Add to chat',
|
||||
'workspace.copyPath': 'Copy path',
|
||||
'workspace.localComment': 'Local comment',
|
||||
'workspace.commentLine': 'Comment line {line}',
|
||||
'workspace.commentLineTarget': 'Line {line}',
|
||||
'workspace.commentPlaceholder': 'Describe what should change here...',
|
||||
'workspace.addCommentToChat': 'Add comment',
|
||||
|
||||
// ─── Status Bar ──────────────────────────────────────
|
||||
'status.connected': 'Connected',
|
||||
@ -527,6 +534,7 @@ export const en = {
|
||||
'chat.placeholder': 'Ask Claude to edit, debug or explain...',
|
||||
'chat.placeholderMissing': 'This session points to a missing workspace. Create a new session or pick another project.',
|
||||
'chat.addFiles': 'Add files or photos',
|
||||
'chat.workspaceReferencesOnly': 'Added {count} workspace references',
|
||||
'chat.slashCommands': 'Slash commands',
|
||||
'slash.mcp.title': 'Available MCP tools',
|
||||
'slash.mcp.subtitle': 'Global and current-project MCP servers available in this chat context.',
|
||||
|
||||
@ -70,6 +70,13 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'workspace.previewState.missing': '文件不存在。',
|
||||
'workspace.imagePreviewUnavailable': '图片无法预览。',
|
||||
'workspace.previewLineLimit': '仅显示前 {count} 行。完整内容请在编辑器中打开。',
|
||||
'workspace.addToChat': '添加到聊天',
|
||||
'workspace.copyPath': '复制路径',
|
||||
'workspace.localComment': '本地评论',
|
||||
'workspace.commentLine': '评论第 {line} 行',
|
||||
'workspace.commentLineTarget': '第 {line} 行',
|
||||
'workspace.commentPlaceholder': '描述你希望这里怎么改...',
|
||||
'workspace.addCommentToChat': '添加评论',
|
||||
|
||||
// ─── Status Bar ──────────────────────────────────────
|
||||
'status.connected': '已连接',
|
||||
@ -529,6 +536,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.placeholder': '让 Claude 编辑、调试或解释代码...',
|
||||
'chat.placeholderMissing': '此会话指向的工作目录缺失。请新建会话或选择其他项目。',
|
||||
'chat.addFiles': '添加文件或图片',
|
||||
'chat.workspaceReferencesOnly': '已添加 {count} 个工作区引用',
|
||||
'chat.slashCommands': '斜杠命令',
|
||||
'slash.mcp.title': '可用 MCP 工具',
|
||||
'slash.mcp.subtitle': '展示当前聊天上下文里的全局 MCP 和当前项目 MCP。',
|
||||
|
||||
@ -231,6 +231,117 @@ describe('chatStore history mapping', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('restores CLI file mentions as visible attachment chips from transcript history', () => {
|
||||
const messages: MessageEntry[] = [
|
||||
{
|
||||
id: 'user-with-file-mention',
|
||||
type: 'user',
|
||||
timestamp: '2026-04-06T00:00:00.000Z',
|
||||
content: '@"/private/tmp/example/src/sentinel.ts" 这个常量是什么?',
|
||||
},
|
||||
]
|
||||
|
||||
const mapped = mapHistoryMessagesToUiMessages(messages)
|
||||
|
||||
expect(mapped).toMatchObject([
|
||||
{
|
||||
id: 'user-with-file-mention',
|
||||
type: 'user_text',
|
||||
content: '这个常量是什么?',
|
||||
modelContent: '@"/private/tmp/example/src/sentinel.ts" 这个常量是什么?',
|
||||
attachments: [{
|
||||
type: 'file',
|
||||
name: 'sentinel.ts',
|
||||
path: '/private/tmp/example/src/sentinel.ts',
|
||||
}],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps workspace reference chips visible while sending CLI attachment paths', () => {
|
||||
useChatStore.setState({
|
||||
sessions: {
|
||||
[TEST_SESSION_ID]: {
|
||||
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,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
useChatStore.getState().sendMessage(
|
||||
TEST_SESSION_ID,
|
||||
'Notes for attached workspace files:\n- src/App.tsx:L4\n Comment: tighten this',
|
||||
[{
|
||||
type: 'file',
|
||||
name: 'App.tsx',
|
||||
path: '/repo/src/App.tsx',
|
||||
lineStart: 4,
|
||||
lineEnd: 4,
|
||||
note: 'tighten this',
|
||||
quote: 'const value = 1',
|
||||
}],
|
||||
{
|
||||
displayContent: '改这里',
|
||||
displayAttachments: [{
|
||||
type: 'file',
|
||||
name: 'App.tsx',
|
||||
path: 'src/App.tsx',
|
||||
lineStart: 4,
|
||||
lineEnd: 4,
|
||||
note: 'tighten this',
|
||||
quote: 'const value = 1',
|
||||
}],
|
||||
},
|
||||
)
|
||||
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([
|
||||
{
|
||||
type: 'user_text',
|
||||
content: '改这里',
|
||||
modelContent: 'Notes for attached workspace files:\n- src/App.tsx:L4\n Comment: tighten this',
|
||||
attachments: [{
|
||||
type: 'file',
|
||||
name: 'App.tsx',
|
||||
path: 'src/App.tsx',
|
||||
lineStart: 4,
|
||||
lineEnd: 4,
|
||||
note: 'tighten this',
|
||||
quote: 'const value = 1',
|
||||
}],
|
||||
},
|
||||
])
|
||||
expect(sendMock).toHaveBeenCalledWith(
|
||||
TEST_SESSION_ID,
|
||||
{
|
||||
type: 'user_message',
|
||||
content: 'Notes for attached workspace files:\n- src/App.tsx:L4\n Comment: tighten this',
|
||||
attachments: [{
|
||||
type: 'file',
|
||||
name: 'App.tsx',
|
||||
path: '/repo/src/App.tsx',
|
||||
lineStart: 4,
|
||||
lineEnd: 4,
|
||||
note: 'tighten this',
|
||||
quote: 'const value = 1',
|
||||
}],
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps parent tool linkage for live tool events', () => {
|
||||
// Initialize the session first
|
||||
useChatStore.setState({
|
||||
|
||||
@ -92,7 +92,7 @@ type ChatStore = {
|
||||
sessionId: string,
|
||||
content: string,
|
||||
attachments?: AttachmentRef[],
|
||||
options?: { displayContent?: string },
|
||||
options?: { displayContent?: string; displayAttachments?: AttachmentRef[] },
|
||||
) => void
|
||||
respondToPermission: (
|
||||
sessionId: string,
|
||||
@ -265,13 +265,19 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
const userFacingContent =
|
||||
options?.displayContent?.trim() || content.trim()
|
||||
const isMemberSession = !!useTeamStore.getState().getMemberBySessionId(sessionId)
|
||||
const visibleAttachments = options?.displayAttachments ?? attachments
|
||||
const uiAttachments: UIAttachment[] | undefined =
|
||||
attachments && attachments.length > 0
|
||||
? attachments.map((a) => ({
|
||||
visibleAttachments && visibleAttachments.length > 0
|
||||
? visibleAttachments.map((a) => ({
|
||||
type: a.type,
|
||||
name: a.name || a.path || a.mimeType || a.type,
|
||||
path: a.path,
|
||||
data: a.data,
|
||||
mimeType: a.mimeType,
|
||||
lineStart: a.lineStart,
|
||||
lineEnd: a.lineEnd,
|
||||
note: a.note,
|
||||
quote: a.quote,
|
||||
}))
|
||||
: undefined
|
||||
|
||||
@ -309,6 +315,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
id: nextId(),
|
||||
type: 'user_text',
|
||||
content: userFacingContent,
|
||||
...(userFacingContent !== content ? { modelContent: content } : {}),
|
||||
attachments: isMemberSession ? undefined : uiAttachments,
|
||||
timestamp: Date.now(),
|
||||
...(isMemberSession ? { pending: true } : {}),
|
||||
@ -918,6 +925,43 @@ type HistoryMappingOptions = {
|
||||
includeTeammateMessages?: boolean
|
||||
}
|
||||
|
||||
function getReferenceName(referencePath: string): string {
|
||||
const normalized = referencePath.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
const name = normalized.split('/').filter(Boolean).pop()
|
||||
return name || referencePath
|
||||
}
|
||||
|
||||
function extractLeadingFileReferences(text: string): {
|
||||
content: string
|
||||
attachments?: UIAttachment[]
|
||||
modelContent?: string
|
||||
} {
|
||||
const attachments: UIAttachment[] = []
|
||||
let remaining = text
|
||||
|
||||
while (true) {
|
||||
const match = remaining.match(/^@"([^"]+)"\s*/)
|
||||
if (!match?.[1]) break
|
||||
|
||||
attachments.push({
|
||||
type: 'file',
|
||||
name: getReferenceName(match[1]),
|
||||
path: match[1],
|
||||
})
|
||||
remaining = remaining.slice(match[0].length)
|
||||
}
|
||||
|
||||
if (attachments.length === 0) {
|
||||
return { content: text }
|
||||
}
|
||||
|
||||
return {
|
||||
content: remaining.trimStart(),
|
||||
attachments,
|
||||
modelContent: text,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct agentTaskNotifications from history.
|
||||
*
|
||||
@ -1013,7 +1057,15 @@ export function mapHistoryMessagesToUiMessages(
|
||||
})
|
||||
continue
|
||||
}
|
||||
uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: msg.content, timestamp })
|
||||
const parsed = extractLeadingFileReferences(msg.content)
|
||||
uiMessages.push({
|
||||
id: msg.id || nextId(),
|
||||
type: 'user_text',
|
||||
content: parsed.content,
|
||||
...(parsed.modelContent ? { modelContent: parsed.modelContent } : {}),
|
||||
...(parsed.attachments ? { attachments: parsed.attachments } : {}),
|
||||
timestamp,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (msg.type === 'assistant' && typeof msg.content === 'string') {
|
||||
@ -1043,7 +1095,16 @@ export function mapHistoryMessagesToUiMessages(
|
||||
else if (block.type === 'tool_result') uiMessages.push({ id: nextId(), type: 'tool_result', toolUseId: block.tool_use_id ?? '', content: block.content, isError: !!block.is_error, timestamp, parentToolUseId: msg.parentToolUseId })
|
||||
}
|
||||
if (textParts.length > 0 || attachments.length > 0) {
|
||||
uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: textParts.join('\n'), attachments: attachments.length > 0 ? attachments : undefined, timestamp })
|
||||
const parsed = extractLeadingFileReferences(textParts.join('\n'))
|
||||
const allAttachments = [...(parsed.attachments ?? []), ...attachments]
|
||||
uiMessages.push({
|
||||
id: msg.id || nextId(),
|
||||
type: 'user_text',
|
||||
content: parsed.content,
|
||||
...(parsed.modelContent ? { modelContent: parsed.modelContent } : {}),
|
||||
attachments: allAttachments.length > 0 ? allAttachments : undefined,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
69
desktop/src/stores/workspaceChatContextStore.test.ts
Normal file
69
desktop/src/stores/workspaceChatContextStore.test.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatWorkspaceReferencePrompt,
|
||||
useWorkspaceChatContextStore,
|
||||
} from './workspaceChatContextStore'
|
||||
|
||||
const initialState = useWorkspaceChatContextStore.getInitialState()
|
||||
|
||||
describe('workspaceChatContextStore', () => {
|
||||
beforeEach(() => {
|
||||
useWorkspaceChatContextStore.setState(initialState, true)
|
||||
})
|
||||
|
||||
it('deduplicates file references per session', () => {
|
||||
const store = useWorkspaceChatContextStore.getState()
|
||||
|
||||
store.addReference('session-1', {
|
||||
kind: 'file',
|
||||
path: 'src/App.tsx',
|
||||
absolutePath: '/repo/src/App.tsx',
|
||||
name: 'App.tsx',
|
||||
})
|
||||
store.addReference('session-1', {
|
||||
kind: 'file',
|
||||
path: 'src/App.tsx',
|
||||
absolutePath: '/repo/src/App.tsx',
|
||||
name: 'App.tsx',
|
||||
})
|
||||
|
||||
expect(useWorkspaceChatContextStore.getState().referencesBySession['session-1']).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('formats line comments into the request prompt', () => {
|
||||
const prompt = formatWorkspaceReferencePrompt([
|
||||
{
|
||||
id: 'ref-1',
|
||||
kind: 'code-comment',
|
||||
path: 'src/App.tsx',
|
||||
absolutePath: '/repo/src/App.tsx',
|
||||
name: 'App.tsx',
|
||||
lineStart: 12,
|
||||
lineEnd: 12,
|
||||
note: 'Use a clearer name',
|
||||
quote: 'const value = 1',
|
||||
},
|
||||
])
|
||||
|
||||
expect(prompt).toContain('Notes for attached workspace files:')
|
||||
expect(prompt).toContain('- src/App.tsx:L12')
|
||||
expect(prompt).toContain('Comment: Use a clearer name')
|
||||
expect(prompt).toContain('Selected code: const value = 1')
|
||||
expect(prompt).not.toContain('Use the Read tool')
|
||||
expect(prompt).not.toContain('Path: /repo/src/App.tsx')
|
||||
})
|
||||
|
||||
it('does not add prompt text for plain file attachments', () => {
|
||||
const prompt = formatWorkspaceReferencePrompt([
|
||||
{
|
||||
id: 'ref-1',
|
||||
kind: 'file',
|
||||
path: 'src/App.tsx',
|
||||
absolutePath: '/repo/src/App.tsx',
|
||||
name: 'App.tsx',
|
||||
},
|
||||
])
|
||||
|
||||
expect(prompt).toBe('')
|
||||
})
|
||||
})
|
||||
118
desktop/src/stores/workspaceChatContextStore.ts
Normal file
118
desktop/src/stores/workspaceChatContextStore.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type WorkspaceChatReferenceKind = 'file' | 'code-comment'
|
||||
|
||||
export type WorkspaceChatReference = {
|
||||
id: string
|
||||
kind: WorkspaceChatReferenceKind
|
||||
path: string
|
||||
absolutePath?: string
|
||||
name: string
|
||||
lineStart?: number
|
||||
lineEnd?: number
|
||||
note?: string
|
||||
quote?: string
|
||||
}
|
||||
|
||||
type WorkspaceChatContextStore = {
|
||||
referencesBySession: Record<string, WorkspaceChatReference[] | undefined>
|
||||
addReference: (
|
||||
sessionId: string,
|
||||
reference: Omit<WorkspaceChatReference, 'id'> & { id?: string },
|
||||
) => void
|
||||
removeReference: (sessionId: string, referenceId: string) => void
|
||||
clearReferences: (sessionId: string) => void
|
||||
clearSession: (sessionId: string) => void
|
||||
}
|
||||
|
||||
function makeReferenceId(reference: Omit<WorkspaceChatReference, 'id'>) {
|
||||
const linePart = reference.lineStart
|
||||
? `${reference.lineStart}-${reference.lineEnd ?? reference.lineStart}`
|
||||
: 'file'
|
||||
const notePart = reference.note ? reference.note.slice(0, 48) : ''
|
||||
return `${reference.kind}:${reference.path}:${linePart}:${notePart}`
|
||||
}
|
||||
|
||||
function getReferenceDedupKey(reference: WorkspaceChatReference) {
|
||||
if (reference.kind === 'file') return `${reference.kind}:${reference.path}`
|
||||
return `${reference.kind}:${reference.path}:${reference.lineStart ?? ''}:${reference.lineEnd ?? ''}:${reference.note ?? ''}`
|
||||
}
|
||||
|
||||
export function formatWorkspaceReferenceLocation(reference: WorkspaceChatReference) {
|
||||
if (!reference.lineStart) return reference.path
|
||||
const lineEnd = reference.lineEnd && reference.lineEnd !== reference.lineStart
|
||||
? `-L${reference.lineEnd}`
|
||||
: ''
|
||||
return `${reference.path}:L${reference.lineStart}${lineEnd}`
|
||||
}
|
||||
|
||||
export function formatWorkspaceReferencePrompt(references: WorkspaceChatReference[]) {
|
||||
const referencesWithContext = references.filter((reference) =>
|
||||
reference.kind === 'code-comment' ||
|
||||
!!reference.lineStart ||
|
||||
!!reference.note?.trim() ||
|
||||
!!reference.quote?.trim(),
|
||||
)
|
||||
if (referencesWithContext.length === 0) return ''
|
||||
|
||||
const lines = [
|
||||
'Notes for attached workspace files:',
|
||||
...referencesWithContext.map((reference) => {
|
||||
const location = formatWorkspaceReferenceLocation(reference)
|
||||
const parts = [`- ${location}`]
|
||||
if (reference.note?.trim()) parts.push(`Comment: ${reference.note.trim()}`)
|
||||
if (reference.quote?.trim()) parts.push(`Selected code: ${reference.quote.trim()}`)
|
||||
return parts.join('\n ')
|
||||
}),
|
||||
]
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export const useWorkspaceChatContextStore = create<WorkspaceChatContextStore>((set) => ({
|
||||
referencesBySession: {},
|
||||
|
||||
addReference: (sessionId, input) =>
|
||||
set((state) => {
|
||||
const reference: WorkspaceChatReference = {
|
||||
...input,
|
||||
id: input.id ?? makeReferenceId(input),
|
||||
}
|
||||
const existing = state.referencesBySession[sessionId] ?? []
|
||||
const nextKey = getReferenceDedupKey(reference)
|
||||
const withoutDuplicate = existing.filter((item) => getReferenceDedupKey(item) !== nextKey)
|
||||
|
||||
return {
|
||||
referencesBySession: {
|
||||
...state.referencesBySession,
|
||||
[sessionId]: [...withoutDuplicate, reference],
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
removeReference: (sessionId, referenceId) =>
|
||||
set((state) => {
|
||||
const existing = state.referencesBySession[sessionId] ?? []
|
||||
return {
|
||||
referencesBySession: {
|
||||
...state.referencesBySession,
|
||||
[sessionId]: existing.filter((reference) => reference.id !== referenceId),
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
clearReferences: (sessionId) =>
|
||||
set((state) => ({
|
||||
referencesBySession: {
|
||||
...state.referencesBySession,
|
||||
[sessionId]: [],
|
||||
},
|
||||
})),
|
||||
|
||||
clearSession: (sessionId) =>
|
||||
set((state) => {
|
||||
if (!(sessionId in state.referencesBySession)) return state
|
||||
const { [sessionId]: _removed, ...rest } = state.referencesBySession
|
||||
return { referencesBySession: rest }
|
||||
}),
|
||||
}))
|
||||
@ -31,13 +31,22 @@ export type AttachmentRef = {
|
||||
path?: string
|
||||
data?: string
|
||||
mimeType?: string
|
||||
lineStart?: number
|
||||
lineEnd?: number
|
||||
note?: string
|
||||
quote?: string
|
||||
}
|
||||
|
||||
export type UIAttachment = {
|
||||
type: 'file' | 'image'
|
||||
name: string
|
||||
path?: string
|
||||
data?: string
|
||||
mimeType?: string
|
||||
lineStart?: number
|
||||
lineEnd?: number
|
||||
note?: string
|
||||
quote?: string
|
||||
}
|
||||
|
||||
// ─── Server → Client ──────────────────────────────────────────────
|
||||
@ -157,7 +166,7 @@ export type TaskSummaryItem = {
|
||||
}
|
||||
|
||||
export type UIMessage =
|
||||
| { id: string; type: 'user_text'; content: string; timestamp: number; attachments?: UIAttachment[]; pending?: boolean }
|
||||
| { id: string; type: 'user_text'; content: string; modelContent?: string; timestamp: number; attachments?: UIAttachment[]; pending?: boolean }
|
||||
| { id: string; type: 'assistant_text'; content: string; timestamp: number; model?: string }
|
||||
| { id: string; type: 'thinking'; content: string; timestamp: number }
|
||||
| { id: string; type: 'tool_use'; toolName: string; toolUseId: string; input: unknown; timestamp: number; parentToolUseId?: string }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user