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:
程序员阿江(Relakkes) 2026-05-01 00:00:32 +08:00
parent ba8c6b8828
commit 80aca8c176
13 changed files with 819 additions and 42 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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) => ({

View File

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

View File

@ -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.',

View File

@ -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。',

View File

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

View File

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

View 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('')
})
})

View 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 }
}),
}))

View File

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