cc-haha/desktop/src/components/chat/MessageList.tsx
程序员阿江(Relakkes) 80aca8c176 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
2026-05-01 00:00:32 +08:00

537 lines
17 KiB
TypeScript

import { useRef, useEffect, useMemo, memo, useState, useCallback } from 'react'
import { ApiError } from '../../api/client'
import { sessionsApi, type SessionRewindResponse } from '../../api/sessions'
import { useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
import { useTeamStore } from '../../stores/teamStore'
import { useUIStore } from '../../stores/uiStore'
import { useTranslation } from '../../i18n'
import type { TranslationKey } from '../../i18n/locales/en'
import { UserMessage } from './UserMessage'
import { AssistantMessage } from './AssistantMessage'
import { ThinkingBlock } from './ThinkingBlock'
import { ToolCallBlock } from './ToolCallBlock'
import { ToolCallGroup } from './ToolCallGroup'
import { ToolResultBlock } from './ToolResultBlock'
import { PermissionDialog } from './PermissionDialog'
import { AskUserQuestion } from './AskUserQuestion'
import { StreamingIndicator } from './StreamingIndicator'
import { InlineTaskSummary } from './InlineTaskSummary'
import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
import type { AgentTaskNotification, UIMessage } from '../../types/chat'
import { ConfirmDialog } from '../shared/ConfirmDialog'
type ToolCall = Extract<UIMessage, { type: 'tool_use' }>
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
type RenderItem =
| { kind: 'tool_group'; toolCalls: ToolCall[]; id: string }
| { kind: 'message'; message: UIMessage }
type RenderModel = {
renderItems: RenderItem[]
toolResultMap: Map<string, ToolResult>
childToolCallsByParent: Map<string, ToolCall[]>
}
type RewindTurnTarget = {
messageId: string
userMessageIndex: number
content: string
expectedContent: string
attachments?: Extract<UIMessage, { type: 'user_text' }>['attachments']
}
type CurrentTurnPreview = {
target: RewindTurnTarget
preview: SessionRewindResponse
workDir: string | null
}
function appendChildToolCall(
childToolCallsByParent: Map<string, ToolCall[]>,
parentToolUseId: string,
toolCall: ToolCall,
) {
const siblings = childToolCallsByParent.get(parentToolUseId)
if (siblings) {
siblings.push(toolCall)
} else {
childToolCallsByParent.set(parentToolUseId, [toolCall])
}
}
export function buildRenderModel(messages: UIMessage[]): RenderModel {
const items: RenderItem[] = []
const toolResultMap = new Map<string, ToolResult>()
const childToolCallsByParent = new Map<string, ToolCall[]>()
const toolUseIds = new Set<string>()
let pendingToolCalls: ToolCall[] = []
const flushGroup = () => {
if (pendingToolCalls.length > 0) {
items.push({
kind: 'tool_group',
toolCalls: [...pendingToolCalls],
id: `group-${pendingToolCalls[0]!.id}`,
})
pendingToolCalls = []
}
}
for (const msg of messages) {
if (msg.type === 'tool_use') {
toolUseIds.add(msg.toolUseId)
}
if (msg.type === 'tool_result') {
toolResultMap.set(msg.toolUseId, msg)
}
}
for (const msg of messages) {
if (msg.type === 'tool_result' && toolUseIds.has(msg.toolUseId)) {
continue
}
if (msg.type === 'tool_result' && msg.parentToolUseId && toolUseIds.has(msg.parentToolUseId)) {
continue
}
if (msg.type === 'tool_use') {
if (msg.parentToolUseId && toolUseIds.has(msg.parentToolUseId)) {
flushGroup()
appendChildToolCall(childToolCallsByParent, msg.parentToolUseId, msg)
continue
}
if (msg.toolName === 'AskUserQuestion') {
flushGroup()
items.push({ kind: 'message', message: msg })
} else {
pendingToolCalls.push(msg)
}
} else {
flushGroup()
items.push({ kind: 'message', message: msg })
}
}
flushGroup()
return { renderItems: items, toolResultMap, childToolCallsByParent }
}
export function getLatestCompletedTurnTarget(messages: UIMessage[]): RewindTurnTarget | null {
let userMessageIndex = -1
let latestTarget: (RewindTurnTarget & { messageOffset: number }) | null = null
for (let messageOffset = 0; messageOffset < messages.length; messageOffset += 1) {
const message = messages[messageOffset]
if (!message || message.type !== 'user_text' || message.pending) continue
userMessageIndex += 1
latestTarget = {
messageId: message.id,
userMessageIndex,
content: message.content,
expectedContent: message.modelContent ?? message.content,
attachments: message.attachments,
messageOffset,
}
}
if (!latestTarget) return null
const hasResponseAfterTarget = messages
.slice(latestTarget.messageOffset + 1)
.some((message) =>
message.type === 'assistant_text' ||
message.type === 'tool_use' ||
message.type === 'tool_result' ||
message.type === 'error' ||
message.type === 'task_summary',
)
if (!hasResponseAfterTarget) return null
const { messageOffset: _messageOffset, ...target } = latestTarget
return target
}
type MessageListProps = {
sessionId?: string | null
compact?: boolean
}
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 48
function isNearScrollBottom(element: HTMLElement) {
return (
element.scrollHeight - element.scrollTop - element.clientHeight <=
AUTO_SCROLL_BOTTOM_THRESHOLD_PX
)
}
export function MessageList({ sessionId, compact = false }: MessageListProps = {}) {
const activeTabId = useTabStore((s) => s.activeTabId)
const resolvedSessionId = sessionId ?? activeTabId
const sessionState = useChatStore((s) =>
resolvedSessionId ? s.sessions[resolvedSessionId] : undefined,
)
const stopGeneration = useChatStore((s) => s.stopGeneration)
const reloadHistory = useChatStore((s) => s.reloadHistory)
const queueComposerPrefill = useChatStore((s) => s.queueComposerPrefill)
const isMemberSession = useTeamStore((s) =>
resolvedSessionId ? Boolean(s.getMemberBySessionId(resolvedSessionId)) : false,
)
const addToast = useUIStore((s) => s.addToast)
const messages = sessionState?.messages ?? []
const chatState = sessionState?.chatState ?? 'idle'
const streamingText = sessionState?.streamingText ?? ''
const activeThinkingId = sessionState?.activeThinkingId ?? null
const agentTaskNotifications = sessionState?.agentTaskNotifications ?? {}
const scrollContainerRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const shouldAutoScrollRef = useRef(true)
const lastSessionIdRef = useRef<string | null | undefined>(resolvedSessionId)
const t = useTranslation()
const [currentTurnPreview, setCurrentTurnPreview] = useState<CurrentTurnPreview | null>(null)
const [currentTurnError, setCurrentTurnError] = useState<string | null>(null)
const [isLoadingCurrentTurnPreview, setIsLoadingCurrentTurnPreview] = useState(false)
const [isUndoingCurrentTurn, setIsUndoingCurrentTurn] = useState(false)
const [currentTurnUndoConfirmOpen, setCurrentTurnUndoConfirmOpen] = useState(false)
const updateAutoScrollState = useCallback(() => {
const container = scrollContainerRef.current
if (!container) return
shouldAutoScrollRef.current = isNearScrollBottom(container)
}, [])
useEffect(() => {
if (lastSessionIdRef.current !== resolvedSessionId) {
shouldAutoScrollRef.current = true
lastSessionIdRef.current = resolvedSessionId
}
if (!shouldAutoScrollRef.current) return
bottomRef.current?.scrollIntoView?.({ behavior: 'smooth' })
}, [messages.length, resolvedSessionId, streamingText])
const { toolResultMap, childToolCallsByParent, renderItems } = useMemo(
() => buildRenderModel(messages),
[messages],
)
const latestTurnTarget = useMemo(() => getLatestCompletedTurnTarget(messages), [messages])
useEffect(() => {
if (
!resolvedSessionId ||
!latestTurnTarget ||
chatState !== 'idle' ||
isMemberSession
) {
setCurrentTurnPreview(null)
setCurrentTurnError(null)
setIsLoadingCurrentTurnPreview(false)
return
}
let cancelled = false
setIsLoadingCurrentTurnPreview(true)
setCurrentTurnPreview(null)
setCurrentTurnError(null)
Promise.all([
sessionsApi.rewind(resolvedSessionId, {
targetUserMessageId: latestTurnTarget.messageId,
userMessageIndex: latestTurnTarget.userMessageIndex,
expectedContent: latestTurnTarget.expectedContent,
dryRun: true,
}),
sessionsApi.getWorkspaceStatus(resolvedSessionId).catch(() => null),
])
.then(([preview, workspaceStatus]) => {
if (cancelled) return
if (!preview.code.available || preview.code.filesChanged.length === 0) {
setCurrentTurnPreview(null)
return
}
setCurrentTurnPreview({
target: latestTurnTarget,
preview,
workDir: workspaceStatus?.workDir ?? null,
})
})
.catch((error) => {
if (cancelled) return
const message =
error instanceof ApiError
? typeof error.body === 'object' && error.body && 'message' in error.body
? String((error.body as { message: unknown }).message)
: error.message
: error instanceof Error
? error.message
: String(error)
setCurrentTurnError(message)
})
.finally(() => {
if (!cancelled) {
setIsLoadingCurrentTurnPreview(false)
}
})
return () => {
cancelled = true
}
}, [chatState, isMemberSession, latestTurnTarget, resolvedSessionId])
const handleUndoCurrentTurn = useCallback(async () => {
if (!resolvedSessionId || !currentTurnPreview || isUndoingCurrentTurn) return
const target = currentTurnPreview.target
setIsUndoingCurrentTurn(true)
setCurrentTurnError(null)
try {
if (chatState !== 'idle') {
stopGeneration(resolvedSessionId)
}
const result = await sessionsApi.rewind(resolvedSessionId, {
targetUserMessageId: target.messageId,
userMessageIndex: target.userMessageIndex,
expectedContent: target.expectedContent,
})
await reloadHistory(resolvedSessionId)
queueComposerPrefill(resolvedSessionId, {
text: target.content,
attachments: target.attachments,
})
addToast({
type: 'success',
message: result.code.available
? t('chat.rewindSuccessWithCode', {
count: result.conversation.messagesRemoved,
})
: t('chat.rewindSuccessConversationOnly', {
count: result.conversation.messagesRemoved,
}),
})
setCurrentTurnPreview(null)
setCurrentTurnUndoConfirmOpen(false)
} catch (error) {
const message =
error instanceof ApiError
? typeof error.body === 'object' && error.body && 'message' in error.body
? String((error.body as { message: unknown }).message)
: error.message
: error instanceof Error
? error.message
: String(error)
setCurrentTurnError(message)
setCurrentTurnUndoConfirmOpen(false)
} finally {
setIsUndoingCurrentTurn(false)
}
}, [
addToast,
chatState,
currentTurnPreview,
isUndoingCurrentTurn,
queueComposerPrefill,
reloadHistory,
resolvedSessionId,
stopGeneration,
t,
])
return (
<div
ref={scrollContainerRef}
onScroll={updateAutoScrollState}
className={`flex-1 overflow-y-auto ${compact ? 'px-3 py-3 pb-5' : 'px-4 py-4'}`}
>
<div className={compact ? 'mx-auto max-w-full' : 'mx-auto max-w-[860px]'}>
{renderItems.map((item) => {
if (item.kind === 'tool_group') {
return (
<ToolCallGroup
key={item.id}
toolCalls={item.toolCalls}
resultMap={toolResultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
isStreaming={
chatState === 'tool_executing' &&
item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId))
}
/>
)
}
const msg = item.message
return (
<MessageBlock
key={msg.id}
message={msg}
activeThinkingId={activeThinkingId}
agentTaskNotifications={agentTaskNotifications}
toolResult={
msg.type === 'tool_use'
? (() => {
const r = toolResultMap.get(msg.toolUseId)
return r ? { content: r.content, isError: r.isError } : null
})()
: null
}
/>
)
})}
{streamingText && (
<AssistantMessage content={streamingText} isStreaming={chatState === 'streaming'} />
)}
{/* Show StreamingIndicator when:
- tool_executing: tool is running
- thinking but no active ThinkingBlock yet: the gap between
sending a message and receiving the first thinking delta */}
{(chatState === 'tool_executing' || (chatState === 'thinking' && !activeThinkingId)) && (
<StreamingIndicator />
)}
{!isLoadingCurrentTurnPreview && currentTurnPreview && resolvedSessionId && (
<CurrentTurnChangeCard
sessionId={resolvedSessionId}
preview={currentTurnPreview.preview}
workDir={currentTurnPreview.workDir}
error={currentTurnError}
isUndoing={isUndoingCurrentTurn}
onUndo={() => {
setCurrentTurnUndoConfirmOpen(true)
}}
/>
)}
{!currentTurnPreview && currentTurnError && (
<div className="mx-auto mb-5 w-full max-w-[860px] rounded-[var(--radius-lg)] border border-[var(--color-error)]/25 bg-[var(--color-error-container)]/18 px-4 py-3 text-xs text-[var(--color-error)]">
{currentTurnError}
</div>
)}
<div ref={bottomRef} />
</div>
<ConfirmDialog
open={currentTurnUndoConfirmOpen}
onClose={() => {
if (!isUndoingCurrentTurn) {
setCurrentTurnUndoConfirmOpen(false)
}
}}
onConfirm={handleUndoCurrentTurn}
title={t('chat.turnChangesConfirmTitle')}
body={t('chat.turnChangesConfirmBody')}
confirmLabel={t('chat.turnChangesConfirmUndo')}
cancelLabel={t('common.cancel')}
confirmVariant="danger"
loading={isUndoingCurrentTurn}
/>
</div>
)
}
export const MessageBlock = memo(function MessageBlock({
message,
activeThinkingId,
agentTaskNotifications,
toolResult,
}: {
message: UIMessage
activeThinkingId: string | null
agentTaskNotifications: Record<string, AgentTaskNotification>
toolResult?: { content: unknown; isError: boolean } | null
}) {
const t = useTranslation()
switch (message.type) {
case 'user_text':
return (
<UserMessage
content={message.content}
attachments={message.attachments}
/>
)
case 'assistant_text':
return <AssistantMessage content={message.content} />
case 'thinking':
return <ThinkingBlock content={message.content} isActive={message.id === activeThinkingId} />
case 'tool_use':
if (message.toolName === 'AskUserQuestion') {
return (
<AskUserQuestion
toolUseId={message.toolUseId}
input={message.input}
result={toolResult?.content}
/>
)
}
return (
<ToolCallBlock
toolName={message.toolName}
input={message.input}
result={toolResult}
agentTaskNotification={
message.toolName === 'Agent'
? agentTaskNotifications[message.toolUseId]
: undefined
}
/>
)
case 'tool_result':
return (
<ToolResultBlock
content={message.content}
isError={message.isError}
standalone
/>
)
case 'permission_request':
return (
<PermissionDialog
requestId={message.requestId}
toolName={message.toolName}
input={message.input}
description={message.description}
/>
)
case 'error': {
const errorKey = message.code ? `error.${message.code}` as TranslationKey : null
const errorText = errorKey ? t(errorKey) : null
const displayMessage = (errorText && errorText !== errorKey) ? errorText : message.message
const showRawDetail =
Boolean(message.message) &&
message.message.trim() !== '' &&
message.message !== displayMessage
return (
<div className="mb-3 px-4 py-2.5 rounded-lg border border-[var(--color-error)]/20 bg-[var(--color-error-container)]/28 text-sm text-[var(--color-error)]">
<strong>Error:</strong> {displayMessage}
{showRawDetail && (
<div className="mt-1 whitespace-pre-wrap text-xs text-[var(--color-on-error-container)]/85">
{message.message}
</div>
)}
</div>
)
}
case 'task_summary':
return <InlineTaskSummary tasks={message.tasks} />
case 'system':
return (
<div className="mb-3 text-center text-xs text-[var(--color-text-tertiary)]">
{message.content}
</div>
)
}
})