mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
桌面端的工作区文件面板现在可以把文件或局部评论加入聊天,发送给 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
537 lines
17 KiB
TypeScript
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>
|
|
)
|
|
}
|
|
})
|