mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
Merge pull request #1418 from NanmiCoder/fix/integrate-1363-1401
fix: preserve history completeness and recover failed inline images
This commit is contained in:
@@ -21,6 +21,61 @@ function imgSrcs(): string[] {
|
||||
}
|
||||
|
||||
describe('InlineImageGallery', () => {
|
||||
it('shows a failed image notice and filename instead of hiding the gallery entry', () => {
|
||||
render(<InlineImageGallery text="See E:/test/denied.png" />)
|
||||
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
|
||||
const notice = screen.getByRole('alert')
|
||||
expect(notice).toBeVisible()
|
||||
expect(notice).toHaveTextContent('Unable to load image')
|
||||
expect(notice).toHaveTextContent('denied.png')
|
||||
expect(notice).toHaveTextContent('The file may be missing or access may be denied.')
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps other images usable and tracks failures by source when the list changes', () => {
|
||||
const { rerender } = render(<InlineImageGallery text="See /tmp/denied.png and /tmp/allowed.png" />)
|
||||
fireEvent.error(screen.getByRole('img', { name: 'denied.png' }))
|
||||
|
||||
expect(screen.getByRole('img', { name: 'allowed.png' })).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: /allowed.png/ }))
|
||||
expect(screen.getByRole('dialog')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
|
||||
rerender(<InlineImageGallery text="See /tmp/new.png and /tmp/denied.png" />)
|
||||
expect(screen.getByRole('img', { name: 'new.png' })).toBeVisible()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('denied.png')
|
||||
expect(screen.queryByRole('img', { name: 'denied.png' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('retries the same protected URL and keeps feedback if the retry fails', () => {
|
||||
render(<InlineImageGallery text="See /tmp/denied.png" />)
|
||||
const source = screen.getByRole('img').getAttribute('src')
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', source)
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
expect(screen.getByRole('alert')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
fireEvent.load(screen.getByRole('img'))
|
||||
expect(screen.getByRole('img')).toBeVisible()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ sessionId: 'new-session', workDir: '/tmp/old' },
|
||||
{ sessionId: 'old-session', workDir: '/tmp/new' },
|
||||
])('clears a failed absolute source when context changes to %j', (context) => {
|
||||
const { rerender } = render(<InlineImageGallery text="See /tmp/denied.png" sessionId="old-session" workDir="/tmp/old" />)
|
||||
const source = screen.getByRole('img').getAttribute('src')
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
rerender(<InlineImageGallery text="See /tmp/denied.png" {...context} />)
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', source)
|
||||
})
|
||||
|
||||
it('suppresses host-managed ImageGen paths when their dedicated card owns the image', () => {
|
||||
render(
|
||||
<InlineImageGallery
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ErrorState } from '@/components/ui/ErrorState'
|
||||
import { useTranslation } from '@/i18n'
|
||||
import { ImageGalleryModal } from './ImageGalleryModal'
|
||||
import { isManagedGeneratedImagePath, localImageFileUrl } from '../../lib/attachmentImages'
|
||||
import {
|
||||
@@ -71,7 +73,14 @@ type Props = {
|
||||
}
|
||||
|
||||
export function InlineImageGallery({ text, sessionId, workDir, changedFiles, suppressManagedGeneratedImages = false }: Props) {
|
||||
const t = useTranslation()
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
||||
const [failureState, setFailureState] = useState(() => ({ sessionId, workDir, sources: new Set<string>() }))
|
||||
// The same absolute URL can become readable in a different workspace/session.
|
||||
if (failureState.sessionId !== sessionId || failureState.workDir !== workDir) {
|
||||
setFailureState({ sessionId, workDir, sources: new Set() })
|
||||
}
|
||||
const failedSources = failureState.sources
|
||||
|
||||
const markdownImageSources = useMemo(
|
||||
() => new Set(extractMarkdownImageSources(text).map(normalizeImageReference)),
|
||||
@@ -155,7 +164,25 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
|
||||
{images.length === 1 ? '1 image' : `${images.length} images`}
|
||||
</div>
|
||||
<div className={`grid gap-2 ${images.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}`}>
|
||||
{images.map((img, i) => (
|
||||
{images.map((img, i) => failedSources.has(img.src) ? (
|
||||
<ErrorState
|
||||
key={img.src}
|
||||
size="sm"
|
||||
title={t('chat.imageLoadFailed')}
|
||||
retryLabel={t('common.retry')}
|
||||
onRetry={() => setFailureState((previous) => {
|
||||
const sources = new Set(previous.sources)
|
||||
sources.delete(img.src)
|
||||
return { ...previous, sources }
|
||||
})}
|
||||
detail={(
|
||||
<>
|
||||
<span className="block break-all">{img.name}</span>
|
||||
{t('chat.imageLoadFailedHint')}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
key={img.src}
|
||||
type="button"
|
||||
@@ -168,9 +195,10 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
|
||||
loading="lazy"
|
||||
className="w-full object-cover"
|
||||
style={{ maxHeight: images.length === 1 ? 400 : 240 }}
|
||||
onError={(e) => {
|
||||
// Hide broken images
|
||||
(e.target as HTMLImageElement).closest('button')!.style.display = 'none'
|
||||
onError={() => {
|
||||
// img errors expose no HTTP status: a denied, missing or invalid
|
||||
// image needs visible feedback without claiming a specific cause.
|
||||
setFailureState((previous) => ({ ...previous, sources: new Set(previous.sources).add(img.src) }))
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/0 opacity-0 transition-all group-hover/image:bg-black/20 group-hover/image:opacity-100">
|
||||
|
||||
@@ -2619,6 +2619,8 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
'chat.workspaceReferencesOnly': 'Added {count} workspace references',
|
||||
'chat.contextReferencesOnly': 'Added {count} references',
|
||||
'chat.addSelectionToChat': 'Add to chat',
|
||||
'chat.imageLoadFailed': 'Unable to load image',
|
||||
'chat.imageLoadFailedHint': 'The file may be missing or access may be denied.',
|
||||
'chat.branchFromHere': 'Fork a new conversation',
|
||||
'chat.branchSuccess': 'Created forked conversation "{title}".',
|
||||
'chat.branchError': 'Failed to branch from this message. Detail: {detail}',
|
||||
|
||||
@@ -2620,6 +2620,8 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'chat.workspaceReferencesOnly': 'ワークスペース参照を {count} 件追加しました',
|
||||
'chat.contextReferencesOnly': '参照を {count} 件追加しました',
|
||||
'chat.addSelectionToChat': 'チャットに追加',
|
||||
'chat.imageLoadFailed': '画像を読み込めません',
|
||||
'chat.imageLoadFailedHint': 'ファイルが存在しないか、アクセスが許可されていない可能性があります。',
|
||||
'chat.branchFromHere': '新しい会話を分岐',
|
||||
'chat.branchSuccess': '分岐した会話「{title}」を作成しました。',
|
||||
'chat.branchError': 'このメッセージから分岐できませんでした。詳細: {detail}',
|
||||
|
||||
@@ -2622,6 +2622,8 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'chat.workspaceReferencesOnly': '작업 공간 참조 {count}개를 추가했습니다',
|
||||
'chat.contextReferencesOnly': '참조 {count}개를 추가했습니다',
|
||||
'chat.addSelectionToChat': '채팅에 추가',
|
||||
'chat.imageLoadFailed': '이미지를 불러올 수 없습니다',
|
||||
'chat.imageLoadFailedHint': '파일이 없거나 접근 권한이 없을 수 있습니다.',
|
||||
'chat.branchFromHere': '새 대화 분기',
|
||||
'chat.branchSuccess': '분기된 대화 "{title}"을(를) 만들었습니다.',
|
||||
'chat.branchError': '이 메시지에서 분기할 수 없습니다. 세부 정보: {detail}',
|
||||
|
||||
@@ -2619,6 +2619,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.workspaceReferencesOnly': '已新增 {count} 個工作區引用',
|
||||
'chat.contextReferencesOnly': '已新增 {count} 個引用',
|
||||
'chat.addSelectionToChat': '新增到對話',
|
||||
'chat.imageLoadFailed': '無法載入圖片',
|
||||
'chat.imageLoadFailedHint': '檔案可能不存在,或沒有存取權限。',
|
||||
'chat.branchFromHere': 'Fork 一個新對話',
|
||||
'chat.branchSuccess': '已 Fork 新對話“{title}”。',
|
||||
'chat.branchError': '從該訊息 Fork 新對話失敗。詳情:{detail}',
|
||||
|
||||
@@ -2618,6 +2618,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.workspaceReferencesOnly': '已添加 {count} 个工作区引用',
|
||||
'chat.contextReferencesOnly': '已添加 {count} 个引用',
|
||||
'chat.addSelectionToChat': '添加到对话',
|
||||
'chat.imageLoadFailed': '无法加载图片',
|
||||
'chat.imageLoadFailedHint': '文件可能不存在,或没有访问权限。',
|
||||
'chat.branchFromHere': 'Fork 一个新对话',
|
||||
'chat.branchSuccess': '已 Fork 新对话“{title}”。',
|
||||
'chat.branchError': '从该消息 Fork 新对话失败。详情:{detail}',
|
||||
|
||||
@@ -161,3 +161,130 @@ test('a multi-megabyte foreground tool output preserves complete paged messages
|
||||
expect(recovery.omittedRecords).toBe(0)
|
||||
expect((await service.getSessionLaunchInfo(id))?.transcriptMessageCount).toBe(160)
|
||||
})
|
||||
|
||||
test('activity overflow does not invalidate complete workspace evidence', async () => {
|
||||
const activity = Array.from({ length: 2050 }, (_, index) => entry('assistant', `agent-${index}`, [
|
||||
{ type: 'tool_use', id: `agent-${index}`, name: 'Agent', input: { description: 'activity only', prompt: 'inspect' } },
|
||||
]))
|
||||
await writeFile(file, [...activity,
|
||||
entry('assistant', 'write', [{ type: 'tool_use', id: 'write', name: 'Write', input: { file_path: '/tmp/file', content: 'complete workspace evidence' } }]),
|
||||
].map(value => JSON.stringify(value)).join('\n') + '\n')
|
||||
const recovery = await service.getSessionHistoryRecovery(id)
|
||||
expect(recovery.status).toBe('incomplete')
|
||||
expect(recovery.completeness?.activity).toBe(false)
|
||||
expect(recovery.completeness?.workspace).toBe(true)
|
||||
expect(recovery.messages).toHaveLength(2048)
|
||||
expect(recovery.messages.some(message => message.id === 'write')).toBe(true)
|
||||
})
|
||||
|
||||
test('workspace evidence discarded by the shared record budget remains incomplete', async () => {
|
||||
const activity = Array.from({ length: 2050 }, (_, index) => entry('assistant', `agent-${index}`, [
|
||||
{ type: 'tool_use', id: `agent-${index}`, name: 'Agent', input: {} },
|
||||
]))
|
||||
await writeFile(file, [
|
||||
entry('assistant', 'write', [{ type: 'tool_use', id: 'write', name: 'Write', input: { file_path: '/tmp/file', content: 'must not be silently lost' } }]),
|
||||
...activity,
|
||||
].map(value => JSON.stringify(value)).join('\n') + '\n')
|
||||
const recovery = await service.getSessionHistoryRecovery(id)
|
||||
expect(recovery.completeness?.activity).toBe(false)
|
||||
expect(recovery.completeness?.workspace).toBe(false)
|
||||
expect(recovery.messages).toHaveLength(2048)
|
||||
expect(recovery.messages.some(message => message.id === 'write')).toBe(false)
|
||||
})
|
||||
|
||||
test('the byte budget marks only discarded activity incomplete', async () => {
|
||||
await writeFile(file, Array.from({ length: 800 }, (_, index) => JSON.stringify(entry('assistant', `agent-${index}`, [
|
||||
{ type: 'tool_use', id: `agent-${index}`, name: 'Agent', input: { description: 'x'.repeat(4096) } },
|
||||
]))).join('\n') + '\n')
|
||||
const recovery = await service.getSessionHistoryRecovery(id)
|
||||
expect(recovery.completeness?.activity).toBe(false)
|
||||
expect(recovery.completeness?.workspace).toBe(true)
|
||||
expect(recovery.messages.length).toBeLessThan(800)
|
||||
expect(recovery.messages.reduce((bytes, message) => bytes + Buffer.byteLength(JSON.stringify(message)), 0)).toBeLessThanOrEqual(3 * 1024 * 1024)
|
||||
})
|
||||
|
||||
test('workspace-only overflow does not mark activity incomplete', async () => {
|
||||
await writeFile(file, Array.from({ length: 2050 }, (_, index) => JSON.stringify(entry('assistant', `write-${index}`, [
|
||||
{ type: 'tool_use', id: `write-${index}`, name: 'Write', input: { file_path: '/tmp/file', content: 'bounded' } },
|
||||
]))).join('\n') + '\n')
|
||||
const recovery = await service.getSessionHistoryRecovery(id)
|
||||
expect(recovery.completeness?.workspace).toBe(false)
|
||||
expect(recovery.completeness?.activity).toBe(true)
|
||||
expect(recovery.messages).toHaveLength(2048)
|
||||
})
|
||||
|
||||
for (const order of ['write-agent', 'agent-write'] as const) {
|
||||
for (const overflow of [false, true]) {
|
||||
test(`mixed ${order} replacement stays incomplete (overflow=${overflow})`, async () => {
|
||||
const write = { type: 'tool_use', id: 'mixed-write', name: 'Write', input: { file_path: '/tmp/file', content: 'must not disappear' } }
|
||||
const agent = { type: 'tool_use', id: 'mixed-agent', name: 'Agent', input: { description: 'inspect' } }
|
||||
const tail = overflow ? Array.from({ length: 2050 }, (_, index) => entry('assistant', `agent-${index}`, [
|
||||
{ type: 'tool_use', id: `agent-${index}`, name: 'Agent', input: {} },
|
||||
])) : []
|
||||
await writeFile(file, [
|
||||
entry('assistant', 'mixed', order === 'write-agent' ? [write, agent] : [agent, write]),
|
||||
...tail,
|
||||
].map(value => JSON.stringify(value)).join('\n') + '\n')
|
||||
const recovery = await service.getSessionHistoryRecovery(id)
|
||||
expect(recovery.status).toBe('incomplete')
|
||||
expect(recovery.completeness?.workspace).toBe(false)
|
||||
expect(recovery.omittedRecords).toBeGreaterThan(0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test('activity-only discarded rows do not reset an earlier oversized workspace failure', async () => {
|
||||
await writeFile(file, [
|
||||
entry('assistant', 'large-write', [{ type: 'tool_use', id: 'large-write', name: 'Write', input: { file_path: '/tmp/file', content: 'x'.repeat(128 * 1024) } }]),
|
||||
...Array.from({ length: 2050 }, (_, index) => entry('assistant', `agent-${index}`, [
|
||||
{ type: 'tool_use', id: `agent-${index}`, name: 'Agent', input: {} },
|
||||
])),
|
||||
].map(value => JSON.stringify(value)).join('\n') + '\n')
|
||||
const recovery = await service.getSessionHistoryRecovery(id)
|
||||
expect(recovery.completeness?.workspace).toBe(false)
|
||||
expect(recovery.completeness?.activity).toBe(false)
|
||||
})
|
||||
|
||||
for (const tailCount of [2045, 2046]) {
|
||||
test(`equal-ordinal workspace and background evidence is conservative at the cap (${tailCount})`, async () => {
|
||||
await writeFile(file, [
|
||||
entry('assistant', 'mixed', [
|
||||
{ type: 'tool_use', id: 'write', name: 'Write', input: { file_path: '/tmp/file', content: 'retained' } },
|
||||
{ type: 'tool_use', id: 'shell', name: 'Bash', input: { command: 'sleep 1', run_in_background: true } },
|
||||
]),
|
||||
entry('user', 'result', [{ type: 'tool_result', tool_use_id: 'shell', content: 'background' }], { toolUseResult: { backgroundTaskId: 'task' } }),
|
||||
...Array.from({ length: tailCount }, (_, index) => entry('assistant', `agent-${index}`, [
|
||||
{ type: 'tool_use', id: `agent-${index}`, name: 'Agent', input: {} },
|
||||
])),
|
||||
].map(value => JSON.stringify(value)).join('\n') + '\n')
|
||||
const recovery = await service.getSessionHistoryRecovery(id)
|
||||
expect(recovery.completeness?.workspace).toBe(tailCount === 2045)
|
||||
expect(recovery.messages).toHaveLength(2048)
|
||||
if (tailCount === 2045) {
|
||||
expect(recovery.messages.some(message => message.id === 'mixed')).toBe(true)
|
||||
expect(recovery.messages.some(message => message.id === 'mixed:background:shell')).toBe(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const order of ['shell-write', 'write-shell'] as const) {
|
||||
test(`mixed tool results preserve lost activity across workspace overflow (${order})`, async () => {
|
||||
const shellResult = { type: 'tool_result', tool_use_id: 'shell', content: 'background' }
|
||||
const writeResult = { type: 'tool_result', tool_use_id: 'write', content: 'written' }
|
||||
await writeFile(file, [
|
||||
...Array.from({ length: 2050 }, (_, index) => entry('assistant', `old-write-${index}`, [
|
||||
{ type: 'tool_use', id: `old-write-${index}`, name: 'Write', input: { file_path: '/tmp/file', content: 'old' } },
|
||||
])),
|
||||
entry('assistant', 'calls', [
|
||||
{ type: 'tool_use', id: 'shell', name: 'Bash', input: { command: 'sleep 1', run_in_background: true } },
|
||||
{ type: 'tool_use', id: 'write', name: 'Write', input: { file_path: '/tmp/file', content: 'latest' } },
|
||||
]),
|
||||
entry('user', 'results', order === 'shell-write' ? [shellResult, writeResult] : [writeResult, shellResult], { toolUseResult: { backgroundTaskId: 'task' } }),
|
||||
].map(value => JSON.stringify(value)).join('\n') + '\n')
|
||||
const recovery = await service.getSessionHistoryRecovery(id)
|
||||
expect(recovery.completeness?.workspace).toBe(false)
|
||||
expect(recovery.completeness?.activity).toBe(false)
|
||||
expect(recovery.messages.some(message => message.id === 'results')).toBe(true)
|
||||
expect(recovery.messages).toHaveLength(2048)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export async function recoverBoundedSessionHistory(options: {
|
||||
const saveTool = database.query('INSERT OR REPLACE INTO tools VALUES (?, ?, ?, ?, ?)')
|
||||
const getTool = database.query('SELECT ordinal, json, legacy, name FROM tools WHERE id = ?')
|
||||
const saveEvidence = database.query('INSERT OR REPLACE INTO evidence VALUES (?, ?, ?, ?)')
|
||||
const getEvidenceCategory = database.query('SELECT category FROM evidence WHERE id = ?')
|
||||
const saveNotice = database.query('INSERT OR REPLACE INTO notices VALUES (?, ?, ?)')
|
||||
const teammate = database.query('INSERT OR IGNORE INTO teammates VALUES (?)')
|
||||
let ordinal = 0
|
||||
@@ -61,6 +62,13 @@ export async function recoverBoundedSessionHistory(options: {
|
||||
const saveActivity = (evidence: Evidence, category: 'activity' | 'workspace' = 'activity') => {
|
||||
const json = JSON.stringify(evidence.message)
|
||||
if (Buffer.byteLength(json) > STATE_RECORD_BYTES) { omitted++; completeness[category] = false; return }
|
||||
const previous = getEvidenceCategory.get(evidence.message.id) as { category: 'activity' | 'workspace' } | null
|
||||
if (previous && previous.category !== category) {
|
||||
// A mixed message can produce multiple projections with the same id.
|
||||
// Replacing one loses evidence even when the surviving row fits the budget.
|
||||
omitted++
|
||||
completeness[previous.category] = false
|
||||
}
|
||||
saveEvidence.run(evidence.message.id, evidence.ordinal, json, category)
|
||||
}
|
||||
const scan = await streamBoundedHistory(options.filePath, (entry) => {
|
||||
@@ -179,7 +187,14 @@ export async function recoverBoundedSessionHistory(options: {
|
||||
for (const row of database.query('SELECT ordinal, json, category FROM evidence ORDER BY ordinal DESC').iterate() as Iterable<{ ordinal: number; json: string; category: 'activity' | 'workspace' }>) {
|
||||
if (options.signal?.aborted) throw options.signal.reason ?? new DOMException('Aborted', 'AbortError')
|
||||
bytes += Buffer.byteLength(row.json)
|
||||
if (bytes > RECOVERY_BYTES || messages.size >= 2048) { omitted++; completeness.activity = false; completeness.workspace = false; break }
|
||||
if (bytes > RECOVERY_BYTES || messages.size >= 2048) {
|
||||
omitted++
|
||||
// Only categories present in the discarded suffix are incomplete. A
|
||||
// long activity history must not invalidate fully retained file changes.
|
||||
const discarded = database.query('SELECT DISTINCT category FROM evidence WHERE ordinal <= ?').all(row.ordinal) as Array<{ category: 'activity' | 'workspace' }>
|
||||
for (const { category } of discarded) completeness[category] = false
|
||||
break
|
||||
}
|
||||
const message = JSON.parse(row.json)
|
||||
messages.set(message.id, { ordinal: row.ordinal, message })
|
||||
if (messages.size % 64 === 0) await new Promise<void>(resolve => setImmediate(resolve))
|
||||
|
||||
Reference in New Issue
Block a user