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
130 lines
5.0 KiB
TypeScript
130 lines
5.0 KiB
TypeScript
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}
|
||
/>
|
||
)}
|
||
</>
|
||
)
|
||
}
|