cc-haha/desktop/src/components/chat/AttachmentGallery.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

130 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo, useState } from 'react'
import { ImageGalleryModal } from './ImageGalleryModal'
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 = {
attachments: AttachmentPreview[]
variant?: 'composer' | 'message'
onRemove?: (id: string) => void
}
export function AttachmentGallery({ attachments, variant = 'message', onRemove }: Props) {
const [activeImageIndex, setActiveImageIndex] = useState<number | null>(null)
const images = useMemo(
() =>
attachments
.filter((attachment) => attachment.type === 'image' && (attachment.previewUrl || attachment.data))
.map((attachment) => ({
src: attachment.previewUrl || attachment.data || '',
name: attachment.name,
})),
[attachments],
)
if (attachments.length === 0) return null
const isComposer = variant === 'composer'
return (
<>
<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 || ''
return (
<div
key={attachment.id || `${attachment.name}-${index}`}
className={isComposer ? 'group relative' : ''}
>
<button
type="button"
onClick={() => setActiveImageIndex(images.findIndex((image) => image.src === src))}
className={
isComposer
? 'overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)]'
: 'overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-left shadow-sm transition-transform hover:scale-[1.01]'
}
>
<img
src={src}
alt={attachment.name}
className={
isComposer
? 'h-16 w-16 object-cover'
: 'max-h-[340px] w-full max-w-[360px] object-cover'
}
/>
</button>
{onRemove && attachment.id && (
<button
type="button"
onClick={() => onRemove(attachment.id!)}
className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-[var(--color-error)] text-[10px] text-white opacity-0 transition-opacity group-hover:opacity-100"
aria-label={`Remove ${attachment.name}`}
>
×
</button>
)}
</div>
)
}
return (
<div
key={attachment.id || `${attachment.name}-${index}`}
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 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-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-[17px]">close</span>
</button>
)}
</div>
)
})}
</div>
{activeImageIndex !== null && activeImageIndex >= 0 && (
<ImageGalleryModal
open={activeImageIndex !== null}
images={images}
activeIndex={activeImageIndex}
onClose={() => setActiveImageIndex(null)}
onSelect={setActiveImageIndex}
/>
)}
</>
)
}