mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
fix(desktop): keep CJK file names whole and stop flagging guessed images
Output cards scanned inline code spans as prose, which excludes CJK on purpose, so `开题报告2.docx` became a `2.docx` card that opened a missing file. Code spans are now parsed whole with the same Unicode-aware parser the rendered chip uses. An image the reply only named by a bare filename, with no changed file corroborating its location, was resolved at the workdir root and showed a red load error when it was not there. Such guesses now disappear on failure; paths spelled out with a directory keep the error and retry.
This commit is contained in:
@@ -181,6 +181,60 @@ describe('InlineImageGallery', () => {
|
||||
expect(srcs[0]).toBe('http://127.0.0.1:4321/preview-fs/s1/outputs/a/frame.png')
|
||||
})
|
||||
|
||||
describe('an image the prose only names, without a path', () => {
|
||||
// A read-only turn ("which commit swapped nodemaven_banner_sep.png?") names a
|
||||
// file it never wrote. Nothing proves it sits at the workdir root, so a failed
|
||||
// load is a wrong guess, not a broken deliverable worth a red error block.
|
||||
it('does not raise the error block when a guessed bare name fails to load', async () => {
|
||||
render(
|
||||
<InlineImageGallery
|
||||
text={'素材也换成 `nodemaven_banner_sep.png`,说明是按月续的'}
|
||||
sessionId="s1"
|
||||
workDir="/w"
|
||||
changedFiles={[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('img')).not.toBeInTheDocument())
|
||||
expect(fetchServerImageBlobUrl).toHaveBeenCalled()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('1 image')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still raises the error block for a path the prose spelled out, though the checkpoint missed it', async () => {
|
||||
// A shell-rendered image never reaches changedFiles; its failure is real.
|
||||
render(
|
||||
<InlineImageGallery
|
||||
text={'渲染结果已保存到 outputs/a/frame.png'}
|
||||
sessionId="s1"
|
||||
workDir="/w"
|
||||
changedFiles={[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('frame.png')
|
||||
})
|
||||
|
||||
it('still raises the error block for a name the turn really wrote', async () => {
|
||||
render(
|
||||
<InlineImageGallery
|
||||
text={'已生成 `banner.png`'}
|
||||
sessionId="s1"
|
||||
workDir="/w"
|
||||
changedFiles={['/w/out/banner.png']}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('banner.png')
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the absolute-file route for a changed image outside the workspace', () => {
|
||||
render(
|
||||
<InlineImageGallery
|
||||
|
||||
@@ -57,6 +57,16 @@ type GalleryImage = {
|
||||
name: string
|
||||
/** Where the file is, for "open in system app". Relative until the workdir is known. */
|
||||
path: string
|
||||
/**
|
||||
* The prose gave only a bare name and nothing the turn wrote corroborates where
|
||||
* the file is, so the URL is a guess. A failed load is then a wrong guess, not
|
||||
* a broken deliverable.
|
||||
*/
|
||||
inferred?: boolean
|
||||
}
|
||||
|
||||
function samePath(left: string, right: string): boolean {
|
||||
return left.replaceAll('\\', '/').toLowerCase() === right.replaceAll('\\', '/').toLowerCase()
|
||||
}
|
||||
|
||||
type Props = {
|
||||
@@ -132,6 +142,7 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
|
||||
// Dedup: an absolute path inside the workspace can be caught by BOTH sources.
|
||||
// Skip a relative target whose basename already appears among the absolute
|
||||
// images, and also collapse duplicate relative targets by resolved src.
|
||||
const proseText = text.replaceAll('\\', '/')
|
||||
const absoluteNames = new Set(absolute.map((img) => img.name))
|
||||
const seenSrc = new Set(absolute.map((img) => img.src))
|
||||
const relative: GalleryImage[] = []
|
||||
@@ -149,23 +160,32 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
|
||||
continue
|
||||
}
|
||||
seenSrc.add(src)
|
||||
relative.push({ src, name, path: resolveAbsoluteOpenPath(relPath, workDir ?? undefined) })
|
||||
const openPath = resolveAbsoluteOpenPath(relPath, workDir ?? undefined)
|
||||
const corroborated = changedFileEvidence?.some((file) => samePath(file, openPath)) ?? false
|
||||
// A path the prose spells out with its directory is a claim, even when the
|
||||
// checkpoint missed it (shell writes are invisible there); only a bare name,
|
||||
// placed at the root or in an inferred directory, is a guess.
|
||||
const namedWithDirectory = /[\\/]/.test(relPath) && proseText.includes(relPath.replaceAll('\\', '/'))
|
||||
relative.push({ src, name, path: openPath, inferred: !corroborated && !namedWithDirectory })
|
||||
}
|
||||
|
||||
return [...absolute, ...relative]
|
||||
}, [changedFileEvidence, imagePaths, markdownImageSources, sessionId, text, workDir])
|
||||
|
||||
if (images.length === 0) return null
|
||||
// A guessed image that failed to load leaves no trace: there is nothing to retry
|
||||
// when the file was never claimed to be there.
|
||||
const visibleImages = images.filter((img) => !(img.inferred && failedSources.has(img.src)))
|
||||
if (visibleImages.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-outline)]">
|
||||
<span className="material-symbols-outlined text-[12px]">image</span>
|
||||
{images.length === 1 ? '1 image' : `${images.length} images`}
|
||||
{visibleImages.length === 1 ? '1 image' : `${visibleImages.length} images`}
|
||||
</div>
|
||||
<div className={`grid gap-2 ${images.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}`}>
|
||||
{images.map((img, i) => failedSources.has(img.src) ? (
|
||||
<div className={`grid gap-2 ${visibleImages.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}`}>
|
||||
{visibleImages.map((img, i) => failedSources.has(img.src) ? (
|
||||
<ErrorState
|
||||
key={img.src}
|
||||
size="sm"
|
||||
@@ -195,7 +215,7 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
|
||||
alt={img.name}
|
||||
loading="lazy"
|
||||
className="w-full object-cover"
|
||||
style={{ maxHeight: images.length === 1 ? 400 : 240 }}
|
||||
style={{ maxHeight: visibleImages.length === 1 ? 400 : 240 }}
|
||||
// img errors expose no HTTP status: a denied, missing or invalid
|
||||
// image needs visible feedback without claiming a specific cause.
|
||||
onFailure={() => setFailureState((previous) => ({ ...previous, sources: new Set(previous.sources).add(img.src) }))}
|
||||
@@ -218,7 +238,7 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
|
||||
{activeIndex !== null && activeIndex >= 0 && (
|
||||
<ImageGalleryModal
|
||||
open={activeIndex !== null}
|
||||
images={images}
|
||||
images={visibleImages}
|
||||
activeIndex={activeIndex}
|
||||
onClose={() => setActiveIndex(null)}
|
||||
onSelect={setActiveIndex}
|
||||
|
||||
@@ -429,6 +429,45 @@ describe('extractAssistantOutputTargets with changedFiles reconciliation', () =>
|
||||
.toEqual(['plan.md', 'out/report.docx'])
|
||||
})
|
||||
|
||||
describe('a file named in an inline code span', () => {
|
||||
// The span, not the sentence, bounds the name: a CJK basename inside backticks
|
||||
// is one file. Scanning it as prose (CJK excluded on purpose) cut
|
||||
// `开题报告2.docx` down to `2.docx` — a card that opens a file that is not there.
|
||||
it('keeps the whole CJK basename', () => {
|
||||
const targets = extractAssistantOutputTargets(
|
||||
'- `开题报告2.docx` — 9/10\n- `开题报告3.docx` — 9/14\n- `开题报告_v2.docx` — 9/17',
|
||||
{ workDir: '/w', changedFiles: [] },
|
||||
)
|
||||
|
||||
expect(targets.map((target) => target.normalizedPath))
|
||||
.toEqual(['开题报告2.docx', '开题报告3.docx', '开题报告_v2.docx'])
|
||||
})
|
||||
|
||||
it('keeps a CJK directory', () => {
|
||||
const targets = extractAssistantOutputTargets(
|
||||
'见 `论文/开题报告终稿.docx`',
|
||||
{ workDir: '/w', changedFiles: [] },
|
||||
)
|
||||
|
||||
expect(targets.map((target) => target.normalizedPath)).toEqual(['论文/开题报告终稿.docx'])
|
||||
})
|
||||
|
||||
it('does not turn a command into a file card', () => {
|
||||
const targets = extractAssistantOutputTargets('运行 `open 开题报告.docx` 即可', {
|
||||
workDir: '/w',
|
||||
changedFiles: [],
|
||||
})
|
||||
|
||||
expect(targets.map((target) => target.normalizedPath)).not.toContain('开题报告.docx')
|
||||
})
|
||||
|
||||
it('still stops prose at the Chinese verb flush against an ASCII path', () => {
|
||||
const targets = extractAssistantOutputTargets('生成了out/report.docx', { workDir: '/w', changedFiles: [] })
|
||||
|
||||
expect(targets.map((target) => target.normalizedPath)).toEqual(['out/report.docx'])
|
||||
})
|
||||
})
|
||||
|
||||
it('places a bare deliverable name in the directory the turn actually wrote into', () => {
|
||||
// The real shape of a "generate three documents" turn: the prose gives the
|
||||
// directory once and then lists basenames. Resolved against the work dir those
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { resolveAssistantFileHref } from './assistantFileContext'
|
||||
import { trimTrailingPunctuation } from './urlBoundary'
|
||||
import { isLinkableFilePath, splitTextByFilePaths } from './filePathBoundary'
|
||||
import { isLinkableFilePath, parseFilePathRef, splitTextByFilePaths } from './filePathBoundary'
|
||||
import { isGeneratedArtifactFile, isOutputResourceFile, isShellProducedDeliverable } from './fileCapabilities'
|
||||
|
||||
export type AssistantOutputTargetKind =
|
||||
@@ -206,6 +206,39 @@ export function extractAssistantOutputTargets(
|
||||
}, createFileKey(fileTarget), treeMatch.position)
|
||||
}
|
||||
|
||||
const queuePlainPath = (path: string, position: number) => {
|
||||
const href = resolveAssistantFileHref(path, content)
|
||||
const fileTarget = toWorkspaceFileTarget(href, workDir)
|
||||
|
||||
if (!fileTarget) {
|
||||
return
|
||||
}
|
||||
|
||||
queueTarget({
|
||||
id: createId(fileTarget.kind, fileTarget.normalizedPath),
|
||||
kind: fileTarget.kind,
|
||||
title: getBasename(fileTarget.normalizedPath),
|
||||
subtitle: fileTarget.normalizedPath,
|
||||
href,
|
||||
normalizedPath: fileTarget.normalizedPath,
|
||||
confidence: 'high',
|
||||
source: 'plain-path',
|
||||
}, createFileKey(fileTarget), position)
|
||||
}
|
||||
|
||||
// A code span is bounded by its backticks, not by the sentence, so a name in
|
||||
// one is read whole — CJK included, as the rendered chip does. The prose scan
|
||||
// below leaves CJK out on purpose (`修改了lib/foo.ts` must not swallow the
|
||||
// verb), and run over `开题报告2.docx` it kept only `2.docx`.
|
||||
const codeSpans = extractInlineCodeSpans(content, codeBlocks)
|
||||
for (const span of codeSpans) {
|
||||
if (isInMarkdownLink(span.start, markdownLinks)) {
|
||||
continue
|
||||
}
|
||||
|
||||
queuePlainPath(span.ref.path, span.start)
|
||||
}
|
||||
|
||||
let plainTextPosition = 0
|
||||
for (const segment of splitTextByFilePaths(content)) {
|
||||
const position = plainTextPosition
|
||||
@@ -223,23 +256,11 @@ export function extractAssistantOutputTargets(
|
||||
continue
|
||||
}
|
||||
|
||||
const href = resolveAssistantFileHref(segment.ref.path, content)
|
||||
const fileTarget = toWorkspaceFileTarget(href, workDir)
|
||||
|
||||
if (!fileTarget) {
|
||||
if (codeSpans.some((span) => position >= span.start && position < span.end)) {
|
||||
continue
|
||||
}
|
||||
|
||||
queueTarget({
|
||||
id: createId(fileTarget.kind, fileTarget.normalizedPath),
|
||||
kind: fileTarget.kind,
|
||||
title: getBasename(fileTarget.normalizedPath),
|
||||
subtitle: fileTarget.normalizedPath,
|
||||
href,
|
||||
normalizedPath: fileTarget.normalizedPath,
|
||||
confidence: 'high',
|
||||
source: 'plain-path',
|
||||
}, createFileKey(fileTarget), position)
|
||||
queuePlainPath(segment.ref.path, position)
|
||||
}
|
||||
|
||||
candidates.sort((left, right) => {
|
||||
@@ -629,6 +650,31 @@ function normalizeMarkdownDestination(destination: string): string {
|
||||
return trimTrailingPunctuation(normalized)
|
||||
}
|
||||
|
||||
type InlineCodeSpan = {
|
||||
start: number
|
||||
end: number
|
||||
ref: NonNullable<ReturnType<typeof parseFilePathRef>>
|
||||
}
|
||||
|
||||
/** Single-line `code` spans outside fenced blocks whose whole content is one file reference. */
|
||||
function extractInlineCodeSpans(content: string, codeBlocks: FencedCodeBlock[]): InlineCodeSpan[] {
|
||||
const spans: InlineCodeSpan[] = []
|
||||
|
||||
for (const match of content.matchAll(/(?<!`)`([^`\n]+)`(?!`)/g)) {
|
||||
const start = match.index ?? 0
|
||||
if (isInCodeBlock(start, codeBlocks)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const ref = parseFilePathRef(match[1] ?? '')
|
||||
if (ref) {
|
||||
spans.push({ start, end: start + match[0].length, ref })
|
||||
}
|
||||
}
|
||||
|
||||
return spans
|
||||
}
|
||||
|
||||
function isInMarkdownLink(position: number, markdownLinks: MarkdownLinkMatch[]): boolean {
|
||||
return markdownLinks.some((link) => position >= link.start && position < link.end)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { WorkspaceService } from '../services/workspaceService.js'
|
||||
import { handlePreviewFs } from '../api/previewFs.js'
|
||||
|
||||
// Boundary shapes behind "the card opens, but the pane says the file is missing":
|
||||
// CJK directories, Unicode normalization, and a deliverable named without its folder.
|
||||
|
||||
const cleanupDirs: string[] = []
|
||||
|
||||
async function makeWorkDir(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ws-edge-'))
|
||||
cleanupDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(cleanupDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('workspace file lookup edge cases', () => {
|
||||
it('reads a docx inside a CJK directory next to a hidden folder', async () => {
|
||||
const workDir = await makeWorkDir()
|
||||
const folder = path.join(workDir, '开题报告')
|
||||
await fs.mkdir(path.join(folder, '.workbuddy'), { recursive: true })
|
||||
await fs.writeFile(path.join(folder, '2.docx'), 'PK')
|
||||
const service = new WorkspaceService(async () => workDir)
|
||||
|
||||
await expect(service.readFile('s1', '开题报告/2.docx')).resolves.toMatchObject({ state: 'ok', previewType: 'docx' })
|
||||
if (process.platform === 'win32') await expect(service.readFile('s1', '开题报告\\2.docx')).resolves.toMatchObject({ state: 'ok', previewType: 'docx' })
|
||||
const raw = await service.resolveRawFile('s1', '开题报告/2.docx')
|
||||
expect(raw.canonicalPath.endsWith('2.docx')).toBe(true)
|
||||
})
|
||||
|
||||
it('finds a CJK-named file when the request uses the other Unicode normalization form', async () => {
|
||||
const workDir = await makeWorkDir()
|
||||
const name = '毕业设计(论文)开题报告 é.docx'
|
||||
await fs.writeFile(path.join(workDir, name.normalize('NFD')), 'PK')
|
||||
const service = new WorkspaceService(async () => workDir)
|
||||
|
||||
// Models emit NFC; some file systems and tools persist NFD (and vice versa).
|
||||
const outcome = await service.readFile('s1', name.normalize('NFC'))
|
||||
expect(outcome.state).not.toBe('missing')
|
||||
})
|
||||
|
||||
it('serves a CJK path through the URL-encoded preview route', async () => {
|
||||
const workDir = await makeWorkDir()
|
||||
await fs.mkdir(path.join(workDir, '图片'), { recursive: true })
|
||||
await fs.writeFile(path.join(workDir, '图片', '横幅 1.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]))
|
||||
const url = new URL(`http://127.0.0.1/preview-fs/s1/${encodeURIComponent('图片')}/${encodeURIComponent('横幅 1.png')}`)
|
||||
|
||||
const res = await handlePreviewFs(url, async () => workDir)
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('reports a bare name as missing when the file lives in a subdirectory (baseline)', async () => {
|
||||
const workDir = await makeWorkDir()
|
||||
await fs.mkdir(path.join(workDir, 'docs', 'static'), { recursive: true })
|
||||
await fs.writeFile(path.join(workDir, 'docs', 'static', 'banner_sep.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]))
|
||||
const service = new WorkspaceService(async () => workDir)
|
||||
|
||||
await expect(service.readFile('s1', 'banner_sep.png')).resolves.toMatchObject({ state: 'missing' })
|
||||
const res = await handlePreviewFs(new URL('http://127.0.0.1/preview-fs/s1/banner_sep.png'), async () => workDir)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user