mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
fix(desktop): silence missing images and translate hardcoded text (#1450)
* fix(desktop): show no error for images that are not there
A reply named the screenshots it had taken as
`/tmp/cc-haha-ui-review/0*.png`, and a red "Unable to load image" tile
with a retry button appeared under it. The inline gallery took the glob
for a file: its absolute-path pattern accepted any character but
whitespace and quotes. Wildcards and substitutions (`*`, `?`, `{name}`,
`${id}`, `$NAME`, `%03d`) now end a path there. Brackets stay, since real
directories use them.
The tile was the larger problem. In the local session history we
checked, 45 of the 115 pictures the gallery tried to show were red tiles
and only 18 existed: files cleaned out of /tmp, outputs deleted since,
web routes, example paths. A retry fixes none of them. Every failure
went red because an <img> error carries no status, but the
authenticated fetch that follows it does. A 400, 403, 404 or 413 from
the file routes now means the picture is not there to show: the inline
tile disappears, and a Markdown image falls back to its alt text. A
server fault, a dropped connection, a refused credential or bytes that
do not decode still show the retryable error, whose hint now names
those causes.
The guessed-name versus spelled-out-path split from #1429 goes: why the
load failed decides, not how the path was written. A server test pins
the 404 for a file missing from an allowed root, which the rule relies
on.
* fix(desktop): translate text typed into components
The header over the pictures in a reply read "1 IMAGE" in every locale.
The text was typed into JSX, and the type of the locale files only
proves that every key has a translation, not that components use one.
A scan of the 246 components found more: Mermaid's error, loading text
and preview hint, "Untitled" in the sidebar, Computer Use's "Failed to
check status.", token counts in /context, labels in the trajectory
view, and screen-reader labels such as the sidebar region and the diff
grid. They now go through t(), reusing a key where one existed and
adding 15 to all five locales. The English text is unchanged.
untranslatedText.test.ts keeps it that way. It reads every component
and fails on JSX text, text-bearing attributes and string literals
rendered as children that skip t(). Key caps, code, icon ligatures and
t() fallbacks are not prose. Product names, units and example values
are listed per file with the reason, and a listed text the source no
longer has fails too. Modal's "Close dialog" stays a known gap: a ui
primitive cannot read the locale, so the label has to come from its
callers.
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
// @vitest-environment node
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Text a user reads has to come from the locale files. A string typed straight
|
||||
* into JSX renders the same in every locale, and nothing else notices: the
|
||||
* "1 image" header over the pictures in a reply stayed English in the Chinese UI.
|
||||
*
|
||||
* So every component is read here. JSX text, the attributes a user reads or a
|
||||
* screen reader speaks, and string literals rendered as children all have to go
|
||||
* through `t()`. Text inside <kbd>, <code>, <pre> or <style>, and icon ligatures,
|
||||
* is not prose. A literal that only stands in when the translate function is
|
||||
* missing (`t?.('key') ?? 'Fallback'`) is not what users see either.
|
||||
*
|
||||
* What is left reads the same in every language — a product or format name, an
|
||||
* example value, a key cap — and is listed below, file by file, with no entry
|
||||
* that the source no longer has.
|
||||
*/
|
||||
|
||||
const srcRoot = path.resolve(import.meta.dirname, '..')
|
||||
|
||||
/** Not part of the product: the dev-only component gallery and test support. */
|
||||
const SKIPPED_DIRECTORIES = new Set([path.join(srcRoot, 'dev'), path.join(srcRoot, 'test')])
|
||||
|
||||
/** Attributes whose value a user reads, or a screen reader speaks. */
|
||||
const TEXT_ATTRIBUTE = /^(?:title|alt|placeholder|label|content|aria-(?:label|description|roledescription|valuetext|placeholder))$|(?:Label|Title|Text|Description|Hint|Message|Tooltip)$/
|
||||
|
||||
/** HTML attributes that take a keyword rather than text, though their names look like text. */
|
||||
const KEYWORD_ATTRIBUTES = new Set(['enterKeyHint'])
|
||||
|
||||
/** Elements whose text is code, a key cap or a style sheet rather than prose. */
|
||||
const NON_PROSE_ELEMENTS = new Set(['kbd', 'Keycap', 'code', 'pre', 'style', 'script'])
|
||||
|
||||
/**
|
||||
* Literals that read the same in every language, by file. Anything else goes
|
||||
* through `t()`; an entry here says why it does not have to.
|
||||
*/
|
||||
const LANGUAGE_NEUTRAL: Record<string, string[]> = {
|
||||
'components/agentTeams/AgentTeamsCanvas.tsx': ['zZ'], // a sleeping agent's glyph
|
||||
'components/agentTeams/AgentTeamsWorkbench.tsx': ['Agent Teams ·'], // product name, kept in every locale
|
||||
'components/chat/MermaidRenderer.tsx': ['Mermaid'], // the diagram language
|
||||
'components/layout/H5ConnectionView.tsx': ['https://chat.example.com'], // example URL
|
||||
'components/layout/Sidebar.tsx': ['cc-', 'haha', 'GitHub'], // wordmark, brand
|
||||
'components/market/FrontmatterPanel.tsx': ['true', 'false'], // YAML values, shown as written
|
||||
'components/market/InstallConfirmDialog.tsx': ['…/skills/'], // a path
|
||||
'components/market/MarketSkillDetail.tsx': ['SKILL.md'], // a file name
|
||||
'components/settings/ProviderImageGenerationFields.tsx': ['sk-...'], // key format
|
||||
'components/workbench/WorkspaceReviewTab.tsx': ['feature/base', 'HEAD~1'], // example git refs
|
||||
'components/workspace/WorkspaceDiffSurface.tsx': ['diff --git'], // the git header, read to screen readers
|
||||
'features/pets/PetSettings.tsx': ['px', 'moon-cat'], // unit, example ID
|
||||
'pages/Connectors.tsx': ['MCP', 'CLI'], // transport names
|
||||
'pages/McpSettings.tsx': ['STDIO'], // transport name
|
||||
'pages/MemorySettings.tsx': ['MARKDOWN'], // file format
|
||||
'pages/settings/AboutSettings.tsx': [ // product, repository and author; example proxy
|
||||
'Claude Code Haha', 'GitHub', 'NanmiCoder/cc-haha', '程序员阿江-Relakkes', 'http://127.0.0.1:7890',
|
||||
],
|
||||
'pages/settings/ChatAppearanceSettings.tsx': ['px', '${}px'], // unit
|
||||
'pages/settings/GeneralSettings.tsx': ['http://127.0.0.1:7890', 'tvly-...'], // example proxy, key format
|
||||
'pages/settings/ProviderSettings.tsx': ['OpenAI Chat', 'OpenAI Responses', 'sk-...'], // API format names, key format
|
||||
'pages/settings/PublicAccessSettings.tsx': ['/remote'], // a URL path
|
||||
}
|
||||
|
||||
/** Untranslated text that needs a wider change first. Keep this short; each entry says why. */
|
||||
const KNOWN_GAPS: Record<string, string[]> = {
|
||||
// A ui primitive cannot read the locale (components/ui/contract.test.ts keeps
|
||||
// stores out of it), so this label has to come from the Modal's callers: 19 of
|
||||
// them, and the 34 behind ActionDialog and ConfirmDialog.
|
||||
'components/ui/Modal.tsx': ['Close dialog'],
|
||||
}
|
||||
|
||||
type Finding = { file: string; line: number; text: string }
|
||||
|
||||
function sourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const full = path.join(directory, entry.name)
|
||||
if (entry.isDirectory()) return SKIPPED_DIRECTORIES.has(full) ? [] : sourceFiles(full)
|
||||
return entry.name.endsWith('.tsx') && !/\.(test|spec)\.tsx$/.test(entry.name) ? [full] : []
|
||||
})
|
||||
}
|
||||
|
||||
function tagName(element: ts.JsxElement): string {
|
||||
return element.openingElement.tagName.getText()
|
||||
}
|
||||
|
||||
/** An icon font renders its ligature (`close`, `image`) as a glyph, not as words. */
|
||||
function isIcon(element: ts.JsxElement): boolean {
|
||||
return element.openingElement.attributes.properties.some((attribute) => (
|
||||
ts.isJsxAttribute(attribute)
|
||||
&& attribute.name.getText() === 'className'
|
||||
&& attribute.initializer !== undefined
|
||||
&& /material-symbols/.test(attribute.initializer.getText())
|
||||
))
|
||||
}
|
||||
|
||||
function isTranslation(expression: ts.Expression): boolean {
|
||||
return ts.isCallExpression(expression) && expression.expression.getText() === 't'
|
||||
}
|
||||
|
||||
/** String literals an expression renders as written: itself, or a branch of `?:`, `&&`, `||`, `??`. */
|
||||
function renderedLiterals(expression: ts.Expression): string[] {
|
||||
if (ts.isParenthesizedExpression(expression)) return renderedLiterals(expression.expression)
|
||||
if (ts.isConditionalExpression(expression)) {
|
||||
return [...renderedLiterals(expression.whenTrue), ...renderedLiterals(expression.whenFalse)]
|
||||
}
|
||||
if (ts.isBinaryExpression(expression)) {
|
||||
const operator = expression.operatorToken.kind
|
||||
if (operator === ts.SyntaxKind.AmpersandAmpersandToken) return renderedLiterals(expression.right)
|
||||
if (operator === ts.SyntaxKind.BarBarToken || operator === ts.SyntaxKind.QuestionQuestionToken) {
|
||||
// A fallback for a missing translate function is not what users see.
|
||||
return isTranslation(expression.left)
|
||||
? []
|
||||
: [...renderedLiterals(expression.left), ...renderedLiterals(expression.right)]
|
||||
}
|
||||
return []
|
||||
}
|
||||
if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) return [expression.text]
|
||||
if (ts.isTemplateExpression(expression)) {
|
||||
return [expression.head.text + expression.templateSpans.map((span) => '${}' + span.literal.text).join('')]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function findUntranslatedText(file: string, code: string): Finding[] {
|
||||
const source = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||
const found: Finding[] = []
|
||||
const report = (node: ts.Node, raw: string) => {
|
||||
const text = raw.replace(/\s+/g, ' ').trim()
|
||||
if (!/\p{L}{2,}/u.test(text.replaceAll('${}', ' '))) return
|
||||
found.push({ file, line: source.getLineAndCharacterOfPosition(node.getStart()).line + 1, text })
|
||||
}
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isJsxElement(node) && (NON_PROSE_ELEMENTS.has(tagName(node)) || isIcon(node))) return
|
||||
if (ts.isJsxText(node)) {
|
||||
report(node, node.text)
|
||||
} else if (
|
||||
ts.isJsxAttribute(node)
|
||||
&& TEXT_ATTRIBUTE.test(node.name.getText())
|
||||
&& !KEYWORD_ATTRIBUTES.has(node.name.getText())
|
||||
) {
|
||||
const value = node.initializer
|
||||
if (value && ts.isStringLiteral(value)) report(node, value.text)
|
||||
else if (value && ts.isJsxExpression(value) && value.expression) {
|
||||
for (const text of renderedLiterals(value.expression)) report(node, text)
|
||||
}
|
||||
} else if (
|
||||
ts.isJsxExpression(node)
|
||||
&& node.expression
|
||||
&& (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))
|
||||
) {
|
||||
for (const text of renderedLiterals(node.expression)) report(node, text)
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(source)
|
||||
return found
|
||||
}
|
||||
|
||||
const files = sourceFiles(srcRoot)
|
||||
const findings = files.flatMap((file) => findUntranslatedText(path.relative(srcRoot, file), readFileSync(file, 'utf8')))
|
||||
|
||||
function isExempt(finding: Finding): boolean {
|
||||
return [LANGUAGE_NEUTRAL, KNOWN_GAPS].some((exemptions) => exemptions[finding.file]?.includes(finding.text))
|
||||
}
|
||||
|
||||
describe('user-visible text goes through the locale files', () => {
|
||||
it('flags text typed into JSX and leaves code, key caps, icons and fallbacks alone', () => {
|
||||
const fixture = [
|
||||
'export function Gallery({ count, t }) {',
|
||||
' return (',
|
||||
' <div title="Gallery" aria-label={`${count} pictures`}>',
|
||||
" {count === 1 ? '1 image' : `${count} images`}",
|
||||
' <span>Unable to load image</span>',
|
||||
' <input enterKeyHint="search" placeholder="Search skills" />',
|
||||
' <span className="material-symbols-outlined">image</span>',
|
||||
' <kbd>Esc</kbd>',
|
||||
' <code>diff --git</code>',
|
||||
" <span>{t?.('chat.retry') ?? 'Retry'}</span>",
|
||||
" <span title={t('chat.retry')}>{t('chat.retry')}</span>",
|
||||
' <span>{count}</span>',
|
||||
' </div>',
|
||||
' )',
|
||||
'}',
|
||||
].join('\n')
|
||||
|
||||
expect(findUntranslatedText('Gallery.tsx', fixture).map((finding) => finding.text)).toEqual([
|
||||
'Gallery',
|
||||
'${} pictures',
|
||||
'1 image',
|
||||
'${} images',
|
||||
'Unable to load image',
|
||||
'Search skills',
|
||||
])
|
||||
})
|
||||
|
||||
it('reads the source tree it is guarding', () => {
|
||||
// A scan that found nothing would pass every rule below.
|
||||
expect(files.length).toBeGreaterThan(200)
|
||||
expect(files.some((file) => file.endsWith('InlineImageGallery.tsx'))).toBe(true)
|
||||
})
|
||||
|
||||
it('routes every text a user reads through t()', () => {
|
||||
const untranslated = findings
|
||||
.filter((finding) => !isExempt(finding))
|
||||
.map((finding) => `${finding.file}:${finding.line} ${finding.text}`)
|
||||
|
||||
// Put the text in all five locale files and render it with t(). Only text that
|
||||
// reads the same in every language belongs in LANGUAGE_NEUTRAL.
|
||||
expect(untranslated).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps no exemption for text the source no longer has', () => {
|
||||
const stale = [LANGUAGE_NEUTRAL, KNOWN_GAPS].flatMap((exemptions) => Object.entries(exemptions).flatMap(
|
||||
([file, texts]) => texts
|
||||
.filter((text) => !findings.some((finding) => finding.file === file && finding.text === text))
|
||||
.map((text) => `${file} ${text}`),
|
||||
))
|
||||
|
||||
expect(stale).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const fetchServerImageBlobUrl = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../../lib/authedImage', () => ({ fetchServerImageBlobUrl }))
|
||||
|
||||
import { ApiError } from '../../api/client'
|
||||
import { AuthedImage } from './AuthedImage'
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -42,6 +43,35 @@ describe('AuthedImage', () => {
|
||||
await waitFor(() => expect(onFailure).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
// A file the server will not serve is not there to show; anything else is a
|
||||
// load that should have worked.
|
||||
it.each([
|
||||
[404, 'unavailable'],
|
||||
[403, 'unavailable'],
|
||||
[400, 'unavailable'],
|
||||
[413, 'unavailable'],
|
||||
[401, 'failed'],
|
||||
[500, 'failed'],
|
||||
] as const)('reports HTTP %i from the authenticated attempt as %s', async (status, failure) => {
|
||||
fetchServerImageBlobUrl.mockRejectedValue(new ApiError(status, {}))
|
||||
const onFailure = vi.fn()
|
||||
render(<AuthedImage src="http://127.0.0.1:1/a.png" alt="a" onFailure={onFailure} />)
|
||||
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
|
||||
await waitFor(() => expect(onFailure).toHaveBeenCalledWith(failure))
|
||||
})
|
||||
|
||||
it('reports a dropped connection as a failure, not as a file that is not there', async () => {
|
||||
fetchServerImageBlobUrl.mockRejectedValue(new TypeError('Failed to fetch'))
|
||||
const onFailure = vi.fn()
|
||||
render(<AuthedImage src="http://127.0.0.1:1/a.png" alt="a" onFailure={onFailure} />)
|
||||
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
|
||||
await waitFor(() => expect(onFailure).toHaveBeenCalledWith('failed'))
|
||||
})
|
||||
|
||||
it('does not apply a result that arrives after the image is gone, and frees it', async () => {
|
||||
let finish!: (url: string) => void
|
||||
fetchServerImageBlobUrl.mockReturnValue(new Promise<string>((resolve) => { finish = resolve }))
|
||||
@@ -89,6 +119,8 @@ describe('AuthedImage', () => {
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
expect(onFailure).toHaveBeenCalledTimes(1)
|
||||
// The server sent the file and it did not decode: there, and broken.
|
||||
expect(onFailure).toHaveBeenCalledWith('failed')
|
||||
})
|
||||
|
||||
it('discards a late copy after switching sources, even when returning to the original source', async () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ImgHTMLAttributes } from 'react'
|
||||
import { useAuthedImageFallback } from '@/lib/useAuthedImageFallback'
|
||||
import { useAuthedImageFallback, type ImageFailure } from '@/lib/useAuthedImageFallback'
|
||||
|
||||
type Props = Omit<ImgHTMLAttributes<HTMLImageElement>, 'onError'> & {
|
||||
/** Runs once the image has failed even with the app's credential. */
|
||||
onFailure?: () => void
|
||||
/** Runs once the image has failed even with the app's credential, saying why. */
|
||||
onFailure?: (failure: ImageFailure) => void
|
||||
/** A user retry skips the potentially cached bare failure and fetches afresh. */
|
||||
retryWithCredential?: boolean
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import '@testing-library/jest-dom'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ApiError } from '../../api/client'
|
||||
import { browserHost } from '../../lib/desktopHost/browserHost'
|
||||
|
||||
// getBaseUrl backs the absolute-path src (/api/filesystem/file).
|
||||
vi.mock('../../api/client', () => ({
|
||||
vi.mock('../../api/client', async (original) => ({
|
||||
...(await original<Record<string, unknown>>()),
|
||||
getBaseUrl: () => 'http://127.0.0.1:3456',
|
||||
}))
|
||||
|
||||
@@ -14,8 +16,9 @@ vi.mock('../../lib/desktopRuntime', () => ({
|
||||
getServerBaseUrl: () => 'http://127.0.0.1:4321',
|
||||
}))
|
||||
|
||||
// The authenticated fallback an <img> error falls back to. It rejects by default,
|
||||
// which is what a missing or denied file does, so the failure notice shows.
|
||||
// The authenticated fallback an <img> error falls back to. It rejects by default
|
||||
// the way a server fault does, so the failure notice shows; a file the server will
|
||||
// not serve is answered with its 4xx status instead.
|
||||
const fetchServerImageBlobUrl = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../../lib/authedImage', () => ({ fetchServerImageBlobUrl }))
|
||||
|
||||
@@ -25,11 +28,13 @@ vi.mock('../../api/sessions', () => ({ sessionsApi: { getWorkspaceTree } }))
|
||||
|
||||
import { InlineImageGallery } from './InlineImageGallery'
|
||||
import { resetDiskListingCacheForTests } from '../../hooks/useDiskConfirmedTargets'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
resetDiskListingCacheForTests()
|
||||
getWorkspaceTree.mockReset().mockResolvedValue({ state: 'missing', path: '', entries: [] })
|
||||
fetchServerImageBlobUrl.mockReset().mockRejectedValue(new Error('403'))
|
||||
fetchServerImageBlobUrl.mockReset().mockRejectedValue(new ApiError(500, { error: 'Internal error' }))
|
||||
// jsdom ships no object-URL support.
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true, writable: true })
|
||||
})
|
||||
@@ -39,37 +44,110 @@ function imgSrcs(): string[] {
|
||||
}
|
||||
|
||||
describe('InlineImageGallery', () => {
|
||||
it('shows a failed image notice and filename instead of hiding the gallery entry', async () => {
|
||||
render(<InlineImageGallery text="See E:/test/denied.png" />)
|
||||
it('shows a retryable notice when an image the server would serve fails to load', async () => {
|
||||
render(<InlineImageGallery text="See E:/test/chart.png" />)
|
||||
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
|
||||
const notice = await screen.findByRole('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(notice).toHaveTextContent('chart.png')
|
||||
expect(notice).toHaveTextContent('The file may be damaged, or the local server did not respond.')
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeVisible()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['en', 'See /tmp/chart.png', '1 image'],
|
||||
['en', 'See /tmp/a.png and /tmp/b.png', '2 images'],
|
||||
['zh', 'See /tmp/a.png and /tmp/b.png', '2 张图片'],
|
||||
['jp', 'See /tmp/a.png and /tmp/b.png', '画像 2 枚'],
|
||||
] as const)('counts the pictures in the reader\'s language (%s: %s)', (locale, text, header) => {
|
||||
// The header was typed in English and read "1 IMAGE" in every locale.
|
||||
useSettingsStore.setState({ locale })
|
||||
render(<InlineImageGallery text={text} />)
|
||||
|
||||
expect(screen.getByText(header)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps other images usable and tracks failures by source when the list changes', async () => {
|
||||
const { rerender } = render(<InlineImageGallery text="See /tmp/denied.png and /tmp/allowed.png" />)
|
||||
fireEvent.error(screen.getByRole('img', { name: 'denied.png' }))
|
||||
const { rerender } = render(<InlineImageGallery text="See /tmp/broken.png and /tmp/chart.png" />)
|
||||
fireEvent.error(screen.getByRole('img', { name: 'broken.png' }))
|
||||
await screen.findByRole('alert')
|
||||
|
||||
expect(screen.getByRole('img', { name: 'allowed.png' })).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: /allowed.png/ }))
|
||||
expect(screen.getByRole('img', { name: 'chart.png' })).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: /chart.png/ }))
|
||||
expect(screen.getByRole('dialog')).toBeVisible()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
|
||||
rerender(<InlineImageGallery text="See /tmp/new.png and /tmp/denied.png" />)
|
||||
rerender(<InlineImageGallery text="See /tmp/new.png and /tmp/broken.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()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('broken.png')
|
||||
expect(screen.queryByRole('img', { name: 'broken.png' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('retries the same protected URL and keeps feedback if the retry fails', async () => {
|
||||
render(<InlineImageGallery text="See /tmp/denied.png" />)
|
||||
describe('an image the server will not serve', () => {
|
||||
// Most paths a reply names that do not load are not there at all: a file
|
||||
// cleaned out of /tmp since, a web route, a name quoted from a log. That is
|
||||
// no fault and no retry fixes it, yet every such reply in the history carried
|
||||
// a red "unable to load" notice.
|
||||
it.each([
|
||||
[404, 'it is missing'],
|
||||
[403, 'it is outside the readable roots'],
|
||||
[400, 'it is not an image file'],
|
||||
])('leaves no trace when the server answers %i: %s', async (status) => {
|
||||
fetchServerImageBlobUrl.mockRejectedValue(new ApiError(status, { error: 'refused' }))
|
||||
render(<InlineImageGallery text="截图已保存到 /tmp/cleaned/chart.png" />)
|
||||
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('img')).not.toBeInTheDocument())
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('1 image')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the images that load beside one that is gone', async () => {
|
||||
fetchServerImageBlobUrl.mockRejectedValue(new ApiError(404, { error: 'File not found' }))
|
||||
render(<InlineImageGallery text="See /tmp/gone.png and /tmp/chart.png" />)
|
||||
|
||||
fireEvent.error(screen.getByRole('img', { name: 'gone.png' }))
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('img', { name: 'gone.png' })).not.toBeInTheDocument())
|
||||
expect(screen.getByRole('img', { name: 'chart.png' })).toBeVisible()
|
||||
expect(screen.getByText('1 image')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves no trace for a missing workspace image the prose spelled out with its directory', async () => {
|
||||
fetchServerImageBlobUrl.mockRejectedValue(new ApiError(404, 'not found'))
|
||||
render(
|
||||
<InlineImageGallery
|
||||
text={'渲染结果已保存到 outputs/a/frame.png'}
|
||||
sessionId="s1"
|
||||
workDir="/w"
|
||||
changedFiles={[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('img')).not.toBeInTheDocument())
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('tries again in another workspace, where the same path can exist', async () => {
|
||||
fetchServerImageBlobUrl.mockRejectedValue(new ApiError(404, { error: 'File not found' }))
|
||||
const { rerender } = render(<InlineImageGallery text="See /tmp/chart.png" sessionId="old" workDir="/tmp/old" />)
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
await waitFor(() => expect(screen.queryByRole('img')).not.toBeInTheDocument())
|
||||
|
||||
rerender(<InlineImageGallery text="See /tmp/chart.png" sessionId="new" workDir="/tmp/old" />)
|
||||
|
||||
expect(screen.getByRole('img', { name: 'chart.png' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
it('retries the same URL and keeps feedback if the retry fails', async () => {
|
||||
render(<InlineImageGallery text="See /tmp/broken.png" />)
|
||||
const source = screen.getByRole('img').getAttribute('src')
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Retry' }))
|
||||
@@ -140,10 +218,10 @@ describe('InlineImageGallery', () => {
|
||||
{ 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 { rerender } = render(<InlineImageGallery text="See /tmp/broken.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} />)
|
||||
rerender(<InlineImageGallery text="See /tmp/broken.png" {...context} />)
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('img')).toHaveAttribute('src', source)
|
||||
})
|
||||
@@ -190,9 +268,10 @@ describe('InlineImageGallery', () => {
|
||||
|
||||
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 () => {
|
||||
// file it never wrote. Resolved at the workdir root it is not there, and a file
|
||||
// that is not there leaves no trace.
|
||||
it('does not raise the error block when a guessed bare name is not there', async () => {
|
||||
fetchServerImageBlobUrl.mockRejectedValue(new ApiError(404, 'not found'))
|
||||
render(
|
||||
<InlineImageGallery
|
||||
text={'素材也换成 `nodemaven_banner_sep.png`,说明是按月续的'}
|
||||
@@ -210,22 +289,6 @@ describe('InlineImageGallery', () => {
|
||||
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('shows the image the workspace really holds when a verb is glued to its name', async () => {
|
||||
getWorkspaceTree.mockResolvedValue({
|
||||
state: 'ok',
|
||||
@@ -251,19 +314,75 @@ describe('InlineImageGallery', () => {
|
||||
]))
|
||||
})
|
||||
|
||||
it('still raises the error block for a name the turn really wrote', async () => {
|
||||
it('shows the notice for a guessed name too when its load fails for another reason', async () => {
|
||||
// How the reply named a file does not decide the notice; why the load failed does.
|
||||
render(
|
||||
<InlineImageGallery
|
||||
text={'已生成 `banner.png`'}
|
||||
text={'素材也换成 `nodemaven_banner_sep.png`,说明是按月续的'}
|
||||
sessionId="s1"
|
||||
workDir="/w"
|
||||
changedFiles={['/w/out/banner.png']}
|
||||
changedFiles={[]}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('banner.png')
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('nodemaven_banner_sep.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a pattern the text quotes, not a file', () => {
|
||||
// The reply pointed at the screenshots it had taken as a glob. No URL loads
|
||||
// `0*.png`, so the gallery showed a red "unable to load" tile with a retry.
|
||||
it('shows nothing for a glob quoted in the reply', () => {
|
||||
render(
|
||||
<InlineImageGallery
|
||||
text={'并截了 8 张关键界面(`/tmp/cc-haha-ui-review/0*.png`,含权限卡片、工具完成态)。'}
|
||||
sessionId="s1"
|
||||
workDir="/w"
|
||||
changedFiles={['/tmp/cc-haha-ui-review/design/index.html']}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryAllByRole('img')).toHaveLength(0)
|
||||
expect(screen.queryByText('1 image')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a wildcard directory listing', 'zsh: no matches found: /tmp/pres-verify/steps/*.png'],
|
||||
['a wildcard name', '截图在 /private/tmp/todo_*.png'],
|
||||
['a single-character wildcard', '帧序列 /tmp/frames/shot-??.png'],
|
||||
['a format-string placeholder', 'plt.savefig(f"/tmp/plots/{name}.png")'],
|
||||
['a template variable', 'logo 路径是 /connectors/${brand.id}.svg'],
|
||||
['a brace expansion', '两张图 /tmp/{before,after}.png'],
|
||||
['a shell variable', 'cp shot.png /tmp/$NAME.png'],
|
||||
['a printf frame pattern', 'ffmpeg -i in.mp4 /tmp/frames/f%03d.png'],
|
||||
])('shows nothing for %s', (_shape, text) => {
|
||||
render(<InlineImageGallery text={text} />)
|
||||
|
||||
expect(screen.queryAllByRole('img')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('still shows the concrete file named beside the pattern', () => {
|
||||
render(
|
||||
<InlineImageGallery
|
||||
text={'截图 `/tmp/cc-haha-ui-review/0*.png`,首屏见 `/tmp/cc-haha-ui-review/01-initial.png`'}
|
||||
sessionId="s1"
|
||||
workDir="/w"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(imgSrcs()).toEqual([
|
||||
'http://127.0.0.1:3456/api/filesystem/file?path=' + encodeURIComponent('/tmp/cc-haha-ui-review/01-initial.png'),
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a real directory whose name has brackets', () => {
|
||||
render(<InlineImageGallery text={'已生成 /w/app/blog/[slug]/opengraph-image.png'} />)
|
||||
|
||||
expect(imgSrcs()).toEqual([
|
||||
'http://127.0.0.1:3456/api/filesystem/file?path=' + encodeURIComponent('/w/app/blog/[slug]/opengraph-image.png'),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -18,11 +18,18 @@ const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp|svg|bmp|avif|ico)$/i
|
||||
/**
|
||||
* Extracts absolute image file paths from text content.
|
||||
* Matches paths like /Users/.../image.png, /tmp/output.jpg, etc.
|
||||
*
|
||||
* Wildcards (`*`, `?`) and substitutions (`{name}`, `${id}`, `$NAME`, `%03d`)
|
||||
* are not path characters: the character that opens one ends the match. A path
|
||||
* like `/tmp/shots/0*.png` or `/tmp/frames/f%03d.png` names a set of files, or a
|
||||
* template for one, that no URL can load; taken as a file, it became a red
|
||||
* "unable to load" tile under a reply that only quoted it. Brackets stay: real
|
||||
* directories use them (`app/[slug]/opengraph-image.png`).
|
||||
*/
|
||||
export function extractImagePaths(text: string): string[] {
|
||||
// Match absolute paths ending with image extensions
|
||||
// Handles paths that may be wrapped in backticks, quotes, or standalone
|
||||
const regex = /(?:^|[\s`"'(])(\/?(?:[A-Za-z]:[\\/]|\/)[^\s`"')<>]+\.(?:png|jpe?g|gif|webp|svg|bmp|avif|ico))/gim
|
||||
const regex = /(?:^|[\s`"'(])(\/?(?:[A-Za-z]:[\\/]|\/)[^\s`"')<>*?{$%]+\.(?:png|jpe?g|gif|webp|svg|bmp|avif|ico))/gim
|
||||
const paths: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
@@ -58,16 +65,6 @@ 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 = {
|
||||
@@ -87,12 +84,17 @@ 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>() }))
|
||||
const [failureState, setFailureState] = useState(() => ({
|
||||
sessionId,
|
||||
workDir,
|
||||
failed: new Set<string>(),
|
||||
unavailable: 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() })
|
||||
setFailureState({ sessionId, workDir, failed: new Set(), unavailable: new Set() })
|
||||
}
|
||||
const failedSources = failureState.sources
|
||||
const failedSources = failureState.failed
|
||||
|
||||
const markdownImageSources = useMemo(
|
||||
() => new Set(extractMarkdownImageSources(text).map(normalizeImageReference)),
|
||||
@@ -153,7 +155,6 @@ 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[] = []
|
||||
@@ -171,21 +172,17 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
|
||||
continue
|
||||
}
|
||||
seenSrc.add(src)
|
||||
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 })
|
||||
relative.push({ src, name, path: resolveAbsoluteOpenPath(relPath, workDir ?? undefined) })
|
||||
}
|
||||
|
||||
return [...absolute, ...relative]
|
||||
}, [changedFileEvidence, imagePaths, relativeTargets, sessionId, text, workDir])
|
||||
}, [imagePaths, relativeTargets, sessionId, workDir])
|
||||
|
||||
// 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)))
|
||||
// A picture the server will not serve — missing, outside the readable roots, not
|
||||
// an image — leaves no trace. The reply only named it, and its text still does:
|
||||
// a file cleaned out of /tmp, a web route, a name quoted from a log. Only a load
|
||||
// that should have worked is an error worth a retry.
|
||||
const visibleImages = images.filter((img) => !failureState.unavailable.has(img.src))
|
||||
if (visibleImages.length === 0) return null
|
||||
|
||||
return (
|
||||
@@ -193,7 +190,7 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
|
||||
<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>
|
||||
{visibleImages.length === 1 ? '1 image' : `${visibleImages.length} images`}
|
||||
{t(visibleImages.length === 1 ? 'chat.imageCountOne' : 'chat.imageCountOther', { count: visibleImages.length })}
|
||||
</div>
|
||||
<div className={`grid gap-2 ${visibleImages.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}`}>
|
||||
{visibleImages.map((img, i) => failedSources.has(img.src) ? (
|
||||
@@ -203,9 +200,9 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
|
||||
title={t('chat.imageLoadFailed')}
|
||||
retryLabel={t('common.retry')}
|
||||
onRetry={() => setFailureState((previous) => {
|
||||
const sources = new Set(previous.sources)
|
||||
sources.delete(img.src)
|
||||
return { ...previous, sources }
|
||||
const failed = new Set(previous.failed)
|
||||
failed.delete(img.src)
|
||||
return { ...previous, failed }
|
||||
})}
|
||||
detail={(
|
||||
<>
|
||||
@@ -227,9 +224,10 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
|
||||
loading="lazy"
|
||||
className="w-full object-cover"
|
||||
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) }))}
|
||||
onFailure={(failure) => setFailureState((previous) => ({
|
||||
...previous,
|
||||
[failure]: new Set(previous[failure]).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">
|
||||
<span className="material-symbols-outlined rounded-full bg-white/90 p-2 text-[20px] text-[var(--color-text-primary)] shadow-lg">
|
||||
|
||||
@@ -280,7 +280,7 @@ function isCapacityCategory(category: ContextCategory) {
|
||||
return category.isDeferred || name.includes('free') || name.includes('autocompact')
|
||||
}
|
||||
|
||||
function ContextStackedBar({ categories, rawMaxTokens }: { categories: ContextCategory[]; rawMaxTokens: number }) {
|
||||
function ContextStackedBar({ categories, rawMaxTokens, t }: { categories: ContextCategory[]; rawMaxTokens: number; t: Translate }) {
|
||||
const activeCategories = categories.filter((category) => !isCapacityCategory(category) && category.tokens > 0)
|
||||
if (activeCategories.length === 0) return null
|
||||
|
||||
@@ -290,7 +290,7 @@ function ContextStackedBar({ categories, rawMaxTokens }: { categories: ContextCa
|
||||
{activeCategories.map((category) => (
|
||||
<div
|
||||
key={category.name}
|
||||
title={`${category.name}: ${formatNumber(category.tokens)} tokens`}
|
||||
title={`${category.name}: ${t('common.tokens', { count: formatNumber(category.tokens) })}`}
|
||||
style={{
|
||||
width: `${Math.max(0.5, (category.tokens / rawMaxTokens) * 100)}%`,
|
||||
backgroundColor: category.color,
|
||||
@@ -408,7 +408,7 @@ function MemoryFilesBreakdown({ files, t }: { files: ContextMemoryFile[]; t: Tra
|
||||
</div>
|
||||
<div className="shrink-0 text-right font-mono">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[var(--color-inspector-muted-strong)]">{file.type}</div>
|
||||
<div className="mt-0.5 text-[11px] text-[var(--color-inspector-muted)]">{formatNumber(file.tokens)} tokens</div>
|
||||
<div className="mt-0.5 text-[11px] text-[var(--color-inspector-muted)]">{t('common.tokens', { count: formatNumber(file.tokens) })}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -480,7 +480,7 @@ function ContextOverview({ context, categories, t }: { context: SessionContextSn
|
||||
<span className="ml-3 align-middle text-sm font-normal text-[var(--color-inspector-accent-secondary)]">[{formatPercent(usedPercent)} {t('slash.inspector.context.used')}]</span>
|
||||
</div>
|
||||
<div className="mt-7">
|
||||
<ContextStackedBar categories={categories} rawMaxTokens={context.rawMaxTokens} />
|
||||
<ContextStackedBar categories={categories} rawMaxTokens={context.rawMaxTokens} t={t} />
|
||||
</div>
|
||||
<div className="mt-8 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div className="rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] px-4 py-3">
|
||||
|
||||
@@ -662,7 +662,7 @@ export function MermaidRenderer({ code }: Props) {
|
||||
<div className="my-4 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-error-soft-hover)]">
|
||||
<div className="flex items-center gap-2 border-b border-[var(--color-error-soft-hover)] bg-[var(--color-error-container)] px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-[var(--color-on-error-container)]">
|
||||
<span className="material-symbols-outlined text-[14px]">error</span>
|
||||
Mermaid Error
|
||||
{t('mermaid.renderError')}
|
||||
</div>
|
||||
<div className="bg-[var(--color-error-soft)] px-3 py-2 font-mono text-[11px] text-[var(--color-on-error-container)]">
|
||||
{error}
|
||||
@@ -676,7 +676,7 @@ export function MermaidRenderer({ code }: Props) {
|
||||
<div className="my-4 flex items-center justify-center rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] py-8">
|
||||
<div className="flex items-center gap-2 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
<span className="material-symbols-outlined animate-spin text-[16px]">progress_activity</span>
|
||||
Rendering diagram...
|
||||
{t('mermaid.rendering')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -716,7 +716,7 @@ export function MermaidRenderer({ code }: Props) {
|
||||
<div className="mx-auto shrink-0 select-none" style={inlineFrameStyle}>
|
||||
<div
|
||||
style={inlineCanvasStyle}
|
||||
aria-label="Mermaid inline canvas"
|
||||
aria-label={t('mermaid.inlineCanvas')}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedSvg ?? '' }}
|
||||
/>
|
||||
</div>
|
||||
@@ -729,7 +729,7 @@ export function MermaidRenderer({ code }: Props) {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
<span className="material-symbols-outlined text-[18px]">account_tree</span>
|
||||
Mermaid Diagram
|
||||
{t('mermaid.previewTitle')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-1">
|
||||
@@ -794,14 +794,14 @@ export function MermaidRenderer({ code }: Props) {
|
||||
ref={previewContentRef}
|
||||
style={previewCanvasStyle}
|
||||
data-dragging={isDraggingPreview ? 'true' : 'false'}
|
||||
aria-label="Mermaid preview canvas"
|
||||
aria-label={t('mermaid.previewCanvas')}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedSvg ?? '' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--color-text-tertiary)]">
|
||||
Use the zoom controls to enlarge the diagram. Drag inside the preview to pan, or use the trackpad, mouse wheel, and scrollbars. Hold Ctrl/Command while scrolling to zoom.
|
||||
{t('mermaid.previewHint')}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -149,7 +149,7 @@ export function ProjectContextChip({
|
||||
tabIndex={0}
|
||||
className="shrink-0 cursor-help rounded-full border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] font-medium uppercase leading-none text-[var(--color-text-tertiary)] outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)]"
|
||||
>
|
||||
worktree
|
||||
{t('sidebar.worktree')}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
|
||||
@@ -620,7 +620,7 @@ function AgentCallCard({
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-outline)]">smart_toy</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-[var(--color-text-primary)]">Agent</span>
|
||||
<span className="text-[13px] font-semibold text-[var(--color-text-primary)]">{t('subagentRun.agent')}</span>
|
||||
{description && (
|
||||
<span className="truncate text-[12px] text-[var(--color-text-secondary)]">
|
||||
{description}
|
||||
|
||||
@@ -931,7 +931,7 @@ export function Sidebar({
|
||||
<aside
|
||||
className="sidebar-panel relative h-full flex flex-col bg-[var(--color-surface-sidebar)] border-r border-[var(--color-border)] select-none"
|
||||
data-state={expanded ? 'open' : 'closed'}
|
||||
aria-label="Sidebar"
|
||||
aria-label={t('sidebar.regionLabel')}
|
||||
>
|
||||
<div
|
||||
data-testid="sidebar-title-region"
|
||||
@@ -1387,7 +1387,7 @@ export function Sidebar({
|
||||
}
|
||||
`}
|
||||
aria-pressed={isBatchMode ? selectedSessionIds.has(session.id) : undefined}
|
||||
title={session.title || 'Untitled'}
|
||||
title={session.title || t('tabs.untitled')}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{isBatchMode ? (
|
||||
@@ -1406,7 +1406,7 @@ export function Sidebar({
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="min-w-0 flex-1 truncate font-medium tracking-normal">{session.title || 'Untitled'}</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium tracking-normal">{session.title || t('tabs.untitled')}</span>
|
||||
{getSessionWorkspaceState(session) === 'missing' && (
|
||||
<span
|
||||
className="flex-shrink-0 text-[10px] text-[var(--color-warning)]"
|
||||
@@ -1654,7 +1654,7 @@ export function Sidebar({
|
||||
<ul className="max-h-40 space-y-1 overflow-y-auto rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-2">
|
||||
{pendingBatchDeleteSessions.slice(0, 5).map((session) => (
|
||||
<li key={session.id} className="truncate text-xs text-[var(--color-text-secondary)]">
|
||||
{session.title || 'Untitled'}
|
||||
{session.title || t('tabs.untitled')}
|
||||
</li>
|
||||
))}
|
||||
{(pendingBatchDeleteSessionIds?.length ?? 0) > 5 && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiError } from '@/api/client'
|
||||
import { useSettingsStore } from '@/stores/settingsStore'
|
||||
import { MarkdownHtml } from './MarkdownHtml'
|
||||
|
||||
@@ -8,7 +9,8 @@ const fetchServerImageBlobUrl = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/lib/authedImage', () => ({ fetchServerImageBlobUrl }))
|
||||
|
||||
beforeEach(() => {
|
||||
fetchServerImageBlobUrl.mockReset().mockRejectedValue(new Error('404'))
|
||||
// A server fault: the load should have worked, so the retryable notice shows.
|
||||
fetchServerImageBlobUrl.mockReset().mockRejectedValue(new ApiError(500, { error: 'Internal error' }))
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true, writable: true })
|
||||
})
|
||||
@@ -44,26 +46,65 @@ describe('MarkdownHtml image lifecycle', () => {
|
||||
})
|
||||
|
||||
it('reacts to a locale change while the failure is visible', async () => {
|
||||
render(<MarkdownHtml html={'<img src="/missing.png" alt="description">'} />)
|
||||
render(<MarkdownHtml html={'<img src="/broken.png" alt="description">'} />)
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
await screen.findByRole('alert')
|
||||
act(() => useSettingsStore.setState({ locale: 'zh' }))
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('无法加载图片')
|
||||
expect(screen.getByRole('button', { name: '重试图片:missing.png' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '重试图片:broken.png' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the placeholder when the retry returns a body that cannot decode', async () => {
|
||||
fetchServerImageBlobUrl.mockRejectedValueOnce(new Error('404')).mockResolvedValueOnce('blob:invalid')
|
||||
const { container } = render(<MarkdownHtml html={'<img src="/missing.png" alt="description">'} />)
|
||||
fetchServerImageBlobUrl.mockRejectedValueOnce(new ApiError(500, {})).mockResolvedValueOnce('blob:invalid')
|
||||
const { container } = render(<MarkdownHtml html={'<img src="/broken.png" alt="description">'} />)
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
await screen.findByRole('alert')
|
||||
fireEvent.click(screen.getByRole('button', { name: /Retry image/ }))
|
||||
await waitFor(() => expect(container.querySelector('img')).toHaveAttribute('src', 'blob:invalid'))
|
||||
fireEvent.error(container.querySelector('img')!)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('missing.png')
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('broken.png')
|
||||
expect(screen.getByRole('button', { name: /Retry image/ })).toBeEnabled()
|
||||
})
|
||||
|
||||
describe('an image the server will not serve', () => {
|
||||
// A picture the reply embedded that is missing or outside the readable roots
|
||||
// is not a fault to retry: like any image that cannot be shown, it falls back
|
||||
// to its text alternative instead of a red notice.
|
||||
it.each([404, 403])('falls back to its description after HTTP %i', async (status) => {
|
||||
fetchServerImageBlobUrl.mockRejectedValue(new ApiError(status, { error: 'refused' }))
|
||||
render(<MarkdownHtml html={'<p>效果如下:<img src="/tmp/cleaned.png" alt="架构图"></p>'} />)
|
||||
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
|
||||
expect(await screen.findByText('架构图')).toBeVisible()
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Retry image/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('leaves nothing in place of one without a description', async () => {
|
||||
fetchServerImageBlobUrl.mockRejectedValue(new ApiError(404, { error: 'File not found' }))
|
||||
const { container } = render(<MarkdownHtml html={'<p>效果如下:<img src="/tmp/cleaned.png" alt=""></p>'} />)
|
||||
|
||||
fireEvent.error(container.querySelector('img')!)
|
||||
|
||||
await waitFor(() => expect(container.querySelector('img')).not.toBeVisible())
|
||||
expect(container).toHaveTextContent(/^效果如下:$/)
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('settles on the description when a retry finds the file gone', async () => {
|
||||
fetchServerImageBlobUrl.mockRejectedValueOnce(new ApiError(500, {}))
|
||||
.mockRejectedValueOnce(new ApiError(404, { error: 'File not found' }))
|
||||
render(<MarkdownHtml html={'<img src="/tmp/chart.png" alt="chart">'} />)
|
||||
fireEvent.error(screen.getByRole('img'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Retry image/ }))
|
||||
|
||||
expect(await screen.findByText('chart')).toBeVisible()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('frees the old image copy and resets failures when the Markdown changes', async () => {
|
||||
fetchServerImageBlobUrl.mockResolvedValue('blob:old')
|
||||
const { rerender } = render(<MarkdownHtml html={'<img src="/a.png" alt="a">'} />)
|
||||
|
||||
@@ -23,10 +23,11 @@ function imageName(src: string, fallback: string): string {
|
||||
function MarkdownImage(props: ImageProps) {
|
||||
const t = useTranslation()
|
||||
const errorId = useId()
|
||||
const [status, setStatus] = useState<'initial' | 'failed' | 'retrying'>('initial')
|
||||
const [status, setStatus] = useState<'initial' | 'unavailable' | 'failed' | 'retrying'>('initial')
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const name = imageName(props.src ?? '', props.alt || t('assistantOutputs.kind.image'))
|
||||
const showError = status !== 'initial'
|
||||
const hidden = status !== 'initial'
|
||||
const showError = status === 'failed' || status === 'retrying'
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -34,11 +35,17 @@ function MarkdownImage(props: ImageProps) {
|
||||
{...props}
|
||||
key={attempt}
|
||||
retryWithCredential={attempt > 0}
|
||||
hidden={showError}
|
||||
style={{ display: showError ? 'none' : undefined }}
|
||||
hidden={hidden}
|
||||
style={{ display: hidden ? 'none' : undefined }}
|
||||
onLoad={() => setStatus('initial')}
|
||||
onFailure={() => setStatus('failed')}
|
||||
onFailure={setStatus}
|
||||
/>
|
||||
{/* A picture the server will not serve (missing, outside the readable roots)
|
||||
is not a fault to retry. Like any image that cannot be shown, it falls
|
||||
back to its text alternative. */}
|
||||
{status === 'unavailable' && props.alt && (
|
||||
<span className="text-[var(--color-text-tertiary)]">{props.alt}</span>
|
||||
)}
|
||||
{showError && (
|
||||
<span className="not-prose my-2 flex max-w-full flex-col gap-2">
|
||||
<span id={errorId}>
|
||||
|
||||
@@ -10,6 +10,7 @@ vi.mock('../../api/client', async (original) => ({
|
||||
getBaseUrl: () => 'http://127.0.0.1:3456',
|
||||
}))
|
||||
|
||||
import { ApiError } from '../../api/client'
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
import { useSettingsStore } from '@/stores/settingsStore'
|
||||
|
||||
@@ -23,10 +24,25 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('MarkdownRenderer local images', () => {
|
||||
// A missing or unreadable picture is no fault, and a red notice for each one —
|
||||
// every embed of a file since cleaned out of /tmp — was noise. It falls back to
|
||||
// its description, as an image that cannot be shown does.
|
||||
it.each([404, 403])('shows the description instead of an error after HTTP %s', async (status) => {
|
||||
apiGetBlob.mockRejectedValue(new ApiError(status, { error: 'refused' }))
|
||||
const { container } = render(
|
||||
<MarkdownRenderer content="" resolveImageSrc={() => LOCAL.replace('chart.png', 'missing.png')} />,
|
||||
)
|
||||
fireEvent.error(container.querySelector('img')!)
|
||||
|
||||
expect(await screen.findByText('description')).toBeVisible()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// QA-002: a terminal image error used to leave only the browser's broken icon.
|
||||
it.each([404, 403])('keeps a named error placeholder after HTTP %s and lets a retry recover', async (status) => {
|
||||
apiGetBlob.mockRejectedValueOnce(new Error(String(status)))
|
||||
.mockRejectedValueOnce(new Error(String(status)))
|
||||
it('keeps a named error placeholder after a server fault and lets a retry recover', async () => {
|
||||
apiGetBlob.mockRejectedValueOnce(new ApiError(500, { error: 'Internal error' }))
|
||||
.mockRejectedValueOnce(new ApiError(500, { error: 'Internal error' }))
|
||||
.mockResolvedValueOnce(new Blob(['png'], { type: 'image/png' }))
|
||||
const { container } = render(
|
||||
<MarkdownRenderer content="" resolveImageSrc={() => LOCAL.replace('chart.png', 'missing.png')} />,
|
||||
@@ -36,7 +52,7 @@ describe('MarkdownRenderer local images', () => {
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent('Unable to load image')
|
||||
expect(alert).toHaveTextContent('missing.png')
|
||||
expect(alert).toHaveTextContent('The file may be missing or access may be denied.')
|
||||
expect(alert).toHaveTextContent('The file may be damaged, or the local server did not respond.')
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument()
|
||||
const retry = screen.getByRole('button', { name: 'Retry image: missing.png' })
|
||||
expect(retry).toHaveAttribute('aria-describedby')
|
||||
@@ -56,7 +72,7 @@ describe('MarkdownRenderer local images', () => {
|
||||
})
|
||||
|
||||
it('keeps failures local to one image and excludes it from the viewer', async () => {
|
||||
apiGetBlob.mockRejectedValue(new Error('404'))
|
||||
apiGetBlob.mockRejectedValue(new ApiError(500, { error: 'Internal error' }))
|
||||
const onImageClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownRenderer content="\n\n" resolveImageSrc={(src) => LOCAL.replace('chart.png', src)} onImageClick={onImageClick} />,
|
||||
@@ -68,7 +84,7 @@ describe('MarkdownRenderer local images', () => {
|
||||
})
|
||||
|
||||
it('does not navigate a surrounding link when retry is clicked', async () => {
|
||||
apiGetBlob.mockRejectedValue(new Error('404'))
|
||||
apiGetBlob.mockRejectedValue(new ApiError(500, { error: 'Internal error' }))
|
||||
const onLinkClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownRenderer content="[](https://example.com)" resolveImageSrc={() => LOCAL} onLinkClick={onLinkClick} />,
|
||||
@@ -82,7 +98,7 @@ describe('MarkdownRenderer local images', () => {
|
||||
})
|
||||
|
||||
it('does not reuse a failure from another renderer with the same parsed Markdown', async () => {
|
||||
apiGetBlob.mockRejectedValue(new Error('404'))
|
||||
apiGetBlob.mockRejectedValue(new ApiError(500, { error: 'Internal error' }))
|
||||
const content = ''
|
||||
const { container } = render(<><MarkdownRenderer content={content} resolveImageSrc={() => LOCAL} /><MarkdownRenderer content={content} resolveImageSrc={() => LOCAL} /></>)
|
||||
const prose = container.querySelectorAll<HTMLElement>('.markdown-prose')
|
||||
|
||||
@@ -164,9 +164,10 @@ function TextResult({ text }: { text: string }) {
|
||||
}
|
||||
|
||||
function ImageChip({ mediaType }: { mediaType?: string }) {
|
||||
const t = useTranslation()
|
||||
return (
|
||||
<Badge tone="neutral" variant="outline" size="xs" pill={false} mono className="w-fit">
|
||||
[image]
|
||||
{t('trace.detail.imageBlock')}
|
||||
{mediaType ? <span>{mediaType}</span> : null}
|
||||
</Badge>
|
||||
)
|
||||
|
||||
@@ -229,7 +229,7 @@ function ResponseContent({
|
||||
{call.error.stack ? (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer font-mono text-[11px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
|
||||
stack
|
||||
{t('trace.detail.stack')}
|
||||
</summary>
|
||||
<pre className="mt-1.5 max-h-[240px] overflow-y-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-[1.6] text-[var(--color-text-tertiary)]">
|
||||
{call.error.stack}
|
||||
|
||||
@@ -615,7 +615,7 @@ export function WorkspaceDiffSurface({
|
||||
data-testid="workspace-code"
|
||||
data-highlight-engine={highlightResult.engine}
|
||||
role="grid"
|
||||
aria-label={`${path} diff`}
|
||||
aria-label={t('workspace.diffGridLabel', { path })}
|
||||
className="m-0 min-w-full font-mono text-[13px] leading-5 text-[var(--color-code-fg)]"
|
||||
style={codeStyle}
|
||||
>
|
||||
|
||||
@@ -707,6 +707,7 @@ export const en = {
|
||||
'sidebar.timeGroup.older': 'Older',
|
||||
'sidebar.collapse': 'Collapse sidebar',
|
||||
'sidebar.expand': 'Expand sidebar',
|
||||
'sidebar.regionLabel': 'Sidebar',
|
||||
'sidebar.resize': 'Resize sidebar',
|
||||
|
||||
// ─── Title Bar ──────────────────────────────────────
|
||||
@@ -765,6 +766,7 @@ export const en = {
|
||||
'workspace.searchResultsTruncated': 'Showing the first {count} results. Refine your search.',
|
||||
'workspace.reviewWorkspace': 'Review workspace',
|
||||
'workspace.itemsCount': '{count} items',
|
||||
'workspace.diffGridLabel': '{path} diff',
|
||||
'workspace.filteredItemsCount': '{visible} of {total} items',
|
||||
'workspace.refresh': 'Refresh workspace',
|
||||
'workspace.resizePanel': 'Resize workspace panel',
|
||||
@@ -1135,6 +1137,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
// Settings > Usage
|
||||
'settings.activity.title': 'Token usage',
|
||||
'settings.activity.profileTitle': 'Profile',
|
||||
'settings.activity.avatarAlt': '{name} avatar',
|
||||
'settings.activity.profilePrivacy': 'Local only',
|
||||
'settings.activity.defaultHandle': 'github.com/NanmiCoder/cc-haha',
|
||||
'settings.activity.editProfile': 'Edit profile',
|
||||
@@ -1447,6 +1450,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
'settings.providers.sonnetModel': 'Sonnet Model',
|
||||
'settings.providers.opusModel': 'Opus Model',
|
||||
'settings.providers.model1mSupportShort': '1M',
|
||||
'settings.providers.model1mSupportAria': '1M support: {slot}',
|
||||
'settings.providers.model1mSupportHint': 'Only tick “1M” when the endpoint really serves a 1M-token context — ticking it otherwise stops auto-compaction from firing.',
|
||||
'settings.providers.model1mSupportTooltip': 'Declares that this model endpoint supports a 1M-token context: the model name gets a [1m] suffix and the context window is set to 1,000,000. If the endpoint is actually smaller, auto-compaction never fires and requests hit the provider’s hard context limit. Clearing the box restores the preset window.',
|
||||
'settings.providers.modelIdPlaceholder': 'e.g. deepseek-v4-flash',
|
||||
@@ -2172,6 +2176,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
// Settings > Computer Use
|
||||
'settings.tab.computerUse': 'Computer Use',
|
||||
'settings.computerUse.title': 'Computer Use',
|
||||
'settings.computerUse.statusCheckFailed': 'Failed to check status.',
|
||||
'settings.computerUse.description': 'Allow Claude to take screenshots, click, type, and control your computer. Requires Python 3. On macOS, accessibility permissions are also needed.',
|
||||
'settings.computerUse.enabledToggle': 'Enabled',
|
||||
'settings.computerUse.enableRiskTitle': 'Enable Computer Use?',
|
||||
@@ -2652,7 +2657,9 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
'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.imageLoadFailedHint': 'The file may be damaged, or the local server did not respond.',
|
||||
'chat.imageCountOne': '{count} image',
|
||||
'chat.imageCountOther': '{count} images',
|
||||
'chat.retryImage': 'Retry image: {name}',
|
||||
'chat.imageRetrying': 'Retrying…',
|
||||
'chat.branchFromHere': 'Fork a new conversation',
|
||||
@@ -3589,6 +3596,8 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
'trace.detail.earlierMessages': 'Show {count} earlier messages',
|
||||
'trace.detail.chars': '{count} chars',
|
||||
'trace.detail.thinking': 'thinking',
|
||||
'trace.detail.imageBlock': '[image]',
|
||||
'trace.detail.stack': 'stack',
|
||||
|
||||
// ─── App Shell ──────────────────────────────────────
|
||||
'app.serverFailed': 'Local server failed to start',
|
||||
@@ -3862,6 +3871,12 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
||||
'mermaid.zoomIn': 'Zoom in',
|
||||
'mermaid.fit': 'Fit',
|
||||
'mermaid.fitDiagram': 'Fit diagram',
|
||||
'mermaid.renderError': 'Mermaid Error',
|
||||
'mermaid.rendering': 'Rendering diagram...',
|
||||
'mermaid.previewTitle': 'Mermaid Diagram',
|
||||
'mermaid.previewHint': 'Use the zoom controls to enlarge the diagram. Drag inside the preview to pan, or use the trackpad, mouse wheel, and scrollbars. Hold Ctrl/Command while scrolling to zoom.',
|
||||
'mermaid.inlineCanvas': 'Mermaid inline canvas',
|
||||
'mermaid.previewCanvas': 'Mermaid preview canvas',
|
||||
'codeViewer.collapse': 'Collapse',
|
||||
'codeViewer.showMoreLines': 'Show {count} more lines',
|
||||
'common.copy': 'Copy',
|
||||
|
||||
@@ -708,6 +708,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'sidebar.timeGroup.older': 'それ以前',
|
||||
'sidebar.collapse': 'サイドバーを折りたたむ',
|
||||
'sidebar.expand': 'サイドバーを展開',
|
||||
'sidebar.regionLabel': 'サイドバー',
|
||||
'sidebar.resize': 'サイドバーの幅を調整',
|
||||
|
||||
// ─── Title Bar ──────────────────────────────────────
|
||||
@@ -766,6 +767,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'workspace.searchResultsTruncated': '最初の {count} 件を表示しています。検索を絞り込んでください。',
|
||||
'workspace.reviewWorkspace': 'ワークスペースをレビュー',
|
||||
'workspace.itemsCount': '{count} 項目',
|
||||
'workspace.diffGridLabel': '{path} の差分',
|
||||
'workspace.filteredItemsCount': '{total} 項目中 {visible} 項目',
|
||||
'workspace.refresh': 'ワークスペースを更新',
|
||||
'workspace.resizePanel': 'ワークスペースパネルのサイズを変更',
|
||||
@@ -1136,6 +1138,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
// Settings > Usage
|
||||
'settings.activity.title': 'トークン使用量',
|
||||
'settings.activity.profileTitle': 'プロフィール',
|
||||
'settings.activity.avatarAlt': '{name} のアバター',
|
||||
'settings.activity.profilePrivacy': 'ローカルのみ',
|
||||
'settings.activity.defaultHandle': 'github.com/NanmiCoder/cc-haha',
|
||||
'settings.activity.editProfile': 'プロフィールを編集',
|
||||
@@ -1448,6 +1451,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'settings.providers.sonnetModel': 'Sonnet モデル',
|
||||
'settings.providers.opusModel': 'Opus モデル',
|
||||
'settings.providers.model1mSupportShort': '1M',
|
||||
'settings.providers.model1mSupportAria': '1M コンテキスト対応:{slot}',
|
||||
'settings.providers.model1mSupportHint': '「1M」は、そのモデルのエンドポイントが実際に 100 万トークンのコンテキストに対応している場合のみチェックしてください。誤ってチェックすると自動圧縮が働かなくなります。',
|
||||
'settings.providers.model1mSupportTooltip': 'このモデルのエンドポイントが 100 万トークンのコンテキストに対応していることを宣言します。モデル名に [1m] が付き、コンテキストウィンドウは 1,000,000 に設定されます。実際のウィンドウが 1M より小さい場合、自動圧縮は実行されず、リクエストはプロバイダーのコンテキスト上限に達します。チェックを外すとプリセットのウィンドウに戻ります。',
|
||||
'settings.providers.modelIdPlaceholder': '例: deepseek-v4-flash',
|
||||
@@ -2173,6 +2177,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
// Settings > Computer Use
|
||||
'settings.tab.computerUse': 'コンピューター操作',
|
||||
'settings.computerUse.title': 'コンピューター操作',
|
||||
'settings.computerUse.statusCheckFailed': 'ステータスを確認できませんでした。',
|
||||
'settings.computerUse.description': 'Claude がスクリーンショットを撮影し、クリック、入力を行い、コンピューターを操作できるようにします。Python 3 が必要です。macOS ではアクセシビリティ権限も必要です。',
|
||||
'settings.computerUse.enabledToggle': '有効',
|
||||
'settings.computerUse.enableRiskTitle': 'Computer Use を有効にしますか?',
|
||||
@@ -2653,7 +2658,9 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'chat.contextReferencesOnly': '参照を {count} 件追加しました',
|
||||
'chat.addSelectionToChat': 'チャットに追加',
|
||||
'chat.imageLoadFailed': '画像を読み込めません',
|
||||
'chat.imageLoadFailedHint': 'ファイルが存在しないか、アクセスが許可されていない可能性があります。',
|
||||
'chat.imageLoadFailedHint': 'ファイルが破損しているか、ローカルサーバーが応答しませんでした。',
|
||||
'chat.imageCountOne': '画像 {count} 枚',
|
||||
'chat.imageCountOther': '画像 {count} 枚',
|
||||
'chat.retryImage': '画像を再読み込み: {name}',
|
||||
'chat.imageRetrying': '再読み込み中…',
|
||||
'chat.branchFromHere': '新しい会話を分岐',
|
||||
@@ -3590,6 +3597,8 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'trace.detail.earlierMessages': '以前の {count} 件のメッセージを表示',
|
||||
'trace.detail.chars': '{count} 文字',
|
||||
'trace.detail.thinking': '思考',
|
||||
'trace.detail.imageBlock': '[画像]',
|
||||
'trace.detail.stack': 'スタック',
|
||||
|
||||
// ─── App Shell ──────────────────────────────────────
|
||||
'app.serverFailed': 'ローカルサーバーの起動に失敗しました',
|
||||
@@ -3863,6 +3872,12 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'mermaid.zoomIn': '拡大',
|
||||
'mermaid.fit': 'フィット',
|
||||
'mermaid.fitDiagram': '図を画面に合わせる',
|
||||
'mermaid.renderError': 'Mermaid の描画エラー',
|
||||
'mermaid.rendering': '図を描画しています…',
|
||||
'mermaid.previewTitle': 'Mermaid 図',
|
||||
'mermaid.previewHint': 'ズームボタンで図を拡大できます。プレビュー内をドラッグして移動するか、トラックパッド、マウスホイール、スクロールバーを使ってください。Ctrl/Command を押しながらスクロールするとズームします。',
|
||||
'mermaid.inlineCanvas': 'Mermaid インラインキャンバス',
|
||||
'mermaid.previewCanvas': 'Mermaid プレビューキャンバス',
|
||||
'codeViewer.collapse': '折りたたむ',
|
||||
'codeViewer.showMoreLines': '残り {count} 行を表示',
|
||||
'common.copy': 'コピー',
|
||||
|
||||
@@ -710,6 +710,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'sidebar.timeGroup.older': '그 이전',
|
||||
'sidebar.collapse': '사이드바 접기',
|
||||
'sidebar.expand': '사이드바 펼치기',
|
||||
'sidebar.regionLabel': '사이드바',
|
||||
'sidebar.resize': '사이드바 너비 조절',
|
||||
|
||||
// ─── Title Bar ──────────────────────────────────────
|
||||
@@ -768,6 +769,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'workspace.searchResultsTruncated': '처음 {count}개 결과를 표시합니다. 검색어를 구체화하세요.',
|
||||
'workspace.reviewWorkspace': '작업 공간 검토',
|
||||
'workspace.itemsCount': '항목 {count}개',
|
||||
'workspace.diffGridLabel': '{path} 변경 내용',
|
||||
'workspace.filteredItemsCount': '항목 {total}개 중 {visible}개',
|
||||
'workspace.refresh': '작업 공간 새로 고침',
|
||||
'workspace.resizePanel': '작업 공간 패널 크기 조정',
|
||||
@@ -1138,6 +1140,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
// Settings > Usage
|
||||
'settings.activity.title': '토큰 사용량',
|
||||
'settings.activity.profileTitle': '프로필',
|
||||
'settings.activity.avatarAlt': '{name} 아바타',
|
||||
'settings.activity.profilePrivacy': '로컬 전용',
|
||||
'settings.activity.defaultHandle': 'github.com/NanmiCoder/cc-haha',
|
||||
'settings.activity.editProfile': '프로필 편집',
|
||||
@@ -1450,6 +1453,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'settings.providers.sonnetModel': 'Sonnet 모델',
|
||||
'settings.providers.opusModel': 'Opus 모델',
|
||||
'settings.providers.model1mSupportShort': '1M',
|
||||
'settings.providers.model1mSupportAria': '1M 컨텍스트 지원: {slot}',
|
||||
'settings.providers.model1mSupportHint': '해당 모델 엔드포인트가 실제로 100만 토큰 컨텍스트를 지원할 때만 「1M」을 선택하세요. 잘못 선택하면 자동 압축이 작동하지 않습니다.',
|
||||
'settings.providers.model1mSupportTooltip': '이 모델 엔드포인트가 100만 토큰 컨텍스트를 지원한다고 선언합니다. 모델 이름에 [1m]이 추가되고 컨텍스트 창이 1,000,000으로 설정됩니다. 엔드포인트의 실제 창이 1M보다 작으면 자동 압축이 실행되지 않고 요청이 공급자의 컨텍스트 한도에 도달합니다. 선택을 해제하면 사전 설정 창으로 되돌아갑니다.',
|
||||
'settings.providers.modelIdPlaceholder': '예: deepseek-v4-flash',
|
||||
@@ -2175,6 +2179,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
// Settings > Computer Use
|
||||
'settings.tab.computerUse': '컴퓨터 사용',
|
||||
'settings.computerUse.title': '컴퓨터 사용',
|
||||
'settings.computerUse.statusCheckFailed': '상태를 확인하지 못했습니다.',
|
||||
'settings.computerUse.description': 'Claude가 스크린샷을 찍고 클릭, 입력하며 컴퓨터를 제어할 수 있도록 허용합니다. Python 3가 필요합니다. macOS에서는 접근성 권한도 필요합니다.',
|
||||
'settings.computerUse.enabledToggle': '사용',
|
||||
'settings.computerUse.enableRiskTitle': 'Computer Use를 활성화할까요?',
|
||||
@@ -2655,7 +2660,9 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'chat.contextReferencesOnly': '참조 {count}개를 추가했습니다',
|
||||
'chat.addSelectionToChat': '채팅에 추가',
|
||||
'chat.imageLoadFailed': '이미지를 불러올 수 없습니다',
|
||||
'chat.imageLoadFailedHint': '파일이 없거나 접근 권한이 없을 수 있습니다.',
|
||||
'chat.imageLoadFailedHint': '파일이 손상되었거나 로컬 서버가 응답하지 않았습니다.',
|
||||
'chat.imageCountOne': '이미지 {count}개',
|
||||
'chat.imageCountOther': '이미지 {count}개',
|
||||
'chat.retryImage': '이미지 다시 시도: {name}',
|
||||
'chat.imageRetrying': '다시 시도 중…',
|
||||
'chat.branchFromHere': '새 대화 분기',
|
||||
@@ -3592,6 +3599,8 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'trace.detail.earlierMessages': '이전 메시지 {count}개 보기',
|
||||
'trace.detail.chars': '{count}자',
|
||||
'trace.detail.thinking': '생각',
|
||||
'trace.detail.imageBlock': '[이미지]',
|
||||
'trace.detail.stack': '스택',
|
||||
|
||||
// ─── App Shell ──────────────────────────────────────
|
||||
'app.serverFailed': '로컬 서버를 시작하지 못했습니다',
|
||||
@@ -3865,6 +3874,12 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'mermaid.zoomIn': '확대',
|
||||
'mermaid.fit': '맞춤',
|
||||
'mermaid.fitDiagram': '다이어그램을 화면에 맞추기',
|
||||
'mermaid.renderError': 'Mermaid 렌더링 오류',
|
||||
'mermaid.rendering': '다이어그램을 렌더링하는 중…',
|
||||
'mermaid.previewTitle': 'Mermaid 다이어그램',
|
||||
'mermaid.previewHint': '확대/축소 버튼으로 다이어그램을 키울 수 있습니다. 미리 보기 안에서 드래그해 이동하거나 트랙패드, 마우스 휠, 스크롤바를 사용하세요. Ctrl/Command를 누른 채 스크롤하면 확대/축소됩니다.',
|
||||
'mermaid.inlineCanvas': 'Mermaid 인라인 캔버스',
|
||||
'mermaid.previewCanvas': 'Mermaid 미리 보기 캔버스',
|
||||
'codeViewer.collapse': '접기',
|
||||
'codeViewer.showMoreLines': '{count}줄 더 보기',
|
||||
'common.copy': '복사',
|
||||
|
||||
@@ -708,6 +708,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'sidebar.timeGroup.older': '更早',
|
||||
'sidebar.collapse': '摺疊側邊欄',
|
||||
'sidebar.expand': '展開側邊欄',
|
||||
'sidebar.regionLabel': '側邊欄',
|
||||
'sidebar.resize': '拖曳調整側邊欄寬度',
|
||||
|
||||
// ─── Title Bar ──────────────────────────────────────
|
||||
@@ -766,6 +767,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'workspace.searchResultsTruncated': '顯示前 {count} 個結果,請縮小搜尋範圍。',
|
||||
'workspace.reviewWorkspace': '審閱工作區',
|
||||
'workspace.itemsCount': '{count} 個項目',
|
||||
'workspace.diffGridLabel': '{path} 的差異',
|
||||
'workspace.filteredItemsCount': '{visible} / {total} 個項目',
|
||||
'workspace.refresh': '重新整理工作區',
|
||||
'workspace.resizePanel': '調整工作區寬度',
|
||||
@@ -1135,6 +1137,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
// Settings > Usage
|
||||
'settings.activity.title': 'Token 用量',
|
||||
'settings.activity.profileTitle': '個人資料',
|
||||
'settings.activity.avatarAlt': '{name} 的頭像',
|
||||
'settings.activity.profilePrivacy': '僅本地',
|
||||
'settings.activity.defaultHandle': 'github.com/NanmiCoder/cc-haha',
|
||||
'settings.activity.editProfile': '編輯個人資料',
|
||||
@@ -1447,6 +1450,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.providers.sonnetModel': 'Sonnet 模型',
|
||||
'settings.providers.opusModel': 'Opus 模型',
|
||||
'settings.providers.model1mSupportShort': '1M',
|
||||
'settings.providers.model1mSupportAria': '1M 上下文支援:{slot}',
|
||||
'settings.providers.model1mSupportHint': '「1M」只在該模型端點確實提供 100 萬 token 上下文時勾選,勾錯會讓自動壓縮失效。',
|
||||
'settings.providers.model1mSupportTooltip': '宣告該模型端點支援 100 萬 token 上下文:模型名稱會附加 [1m],上下文視窗自動填為 1,000,000。若端點實際視窗小於 1M,自動壓縮不會觸發,請求會撞上服務商的上下文硬限制。取消勾選會還原為預設視窗。',
|
||||
'settings.providers.modelIdPlaceholder': '例如: deepseek-v4-flash',
|
||||
@@ -2172,6 +2176,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
// Settings > Computer Use
|
||||
'settings.tab.computerUse': 'Computer Use',
|
||||
'settings.computerUse.title': 'Computer Use',
|
||||
'settings.computerUse.statusCheckFailed': '無法檢查狀態。',
|
||||
'settings.computerUse.description': '允許 Claude 截圖、點選、打字並控制你的電腦。需要 Python 3,macOS 上還需要輔助功能許可權。',
|
||||
'settings.computerUse.enabledToggle': '啟用',
|
||||
'settings.computerUse.enableRiskTitle': '開啟 Computer Use?',
|
||||
@@ -2652,7 +2657,9 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.contextReferencesOnly': '已新增 {count} 個引用',
|
||||
'chat.addSelectionToChat': '新增到對話',
|
||||
'chat.imageLoadFailed': '無法載入圖片',
|
||||
'chat.imageLoadFailedHint': '檔案可能不存在,或沒有存取權限。',
|
||||
'chat.imageLoadFailedHint': '檔案可能已損壞,或本機服務暫時沒有回應。',
|
||||
'chat.imageCountOne': '{count} 張圖片',
|
||||
'chat.imageCountOther': '{count} 張圖片',
|
||||
'chat.retryImage': '重試圖片:{name}',
|
||||
'chat.imageRetrying': '正在重試…',
|
||||
'chat.branchFromHere': 'Fork 一個新對話',
|
||||
@@ -3589,6 +3596,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'trace.detail.earlierMessages': '展開更早的 {count} 則訊息',
|
||||
'trace.detail.chars': '{count} 字元',
|
||||
'trace.detail.thinking': '思考',
|
||||
'trace.detail.imageBlock': '[圖片]',
|
||||
'trace.detail.stack': '呼叫堆疊',
|
||||
|
||||
// ─── 應用外殼 ──────────────────────────────────────
|
||||
'app.serverFailed': '本地服務啟動失敗',
|
||||
@@ -3862,6 +3871,12 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'mermaid.zoomIn': '放大',
|
||||
'mermaid.fit': '適應',
|
||||
'mermaid.fitDiagram': '縮放至適應圖表',
|
||||
'mermaid.renderError': 'Mermaid 渲染失敗',
|
||||
'mermaid.rendering': '正在渲染圖表…',
|
||||
'mermaid.previewTitle': 'Mermaid 圖表',
|
||||
'mermaid.previewHint': '用縮放按鈕放大圖表。在預覽區內拖曳可平移,也可以用觸控板、滑鼠滾輪或捲軸;捲動時按住 Ctrl/Command 可縮放。',
|
||||
'mermaid.inlineCanvas': 'Mermaid 內嵌畫布',
|
||||
'mermaid.previewCanvas': 'Mermaid 預覽畫布',
|
||||
'codeViewer.collapse': '收合',
|
||||
'codeViewer.showMoreLines': '顯示其餘 {count} 行',
|
||||
'common.copy': '複製',
|
||||
|
||||
@@ -707,6 +707,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'sidebar.timeGroup.older': '更早',
|
||||
'sidebar.collapse': '折叠侧边栏',
|
||||
'sidebar.expand': '展开侧边栏',
|
||||
'sidebar.regionLabel': '侧边栏',
|
||||
'sidebar.resize': '拖拽调整侧边栏宽度',
|
||||
|
||||
// ─── Title Bar ──────────────────────────────────────
|
||||
@@ -765,6 +766,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'workspace.searchResultsTruncated': '显示前 {count} 个结果,请缩小搜索范围。',
|
||||
'workspace.reviewWorkspace': '审阅工作区',
|
||||
'workspace.itemsCount': '{count} 个项目',
|
||||
'workspace.diffGridLabel': '{path} 的差异',
|
||||
'workspace.filteredItemsCount': '{visible} / {total} 个项目',
|
||||
'workspace.refresh': '刷新工作区',
|
||||
'workspace.resizePanel': '调整工作区宽度',
|
||||
@@ -1134,6 +1136,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
// Settings > Usage
|
||||
'settings.activity.title': 'Token 用量',
|
||||
'settings.activity.profileTitle': '个人资料',
|
||||
'settings.activity.avatarAlt': '{name} 的头像',
|
||||
'settings.activity.profilePrivacy': '仅本地',
|
||||
'settings.activity.defaultHandle': 'github.com/NanmiCoder/cc-haha',
|
||||
'settings.activity.editProfile': '编辑个人资料',
|
||||
@@ -1446,6 +1449,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.providers.sonnetModel': 'Sonnet 模型',
|
||||
'settings.providers.opusModel': 'Opus 模型',
|
||||
'settings.providers.model1mSupportShort': '1M',
|
||||
'settings.providers.model1mSupportAria': '1M 上下文支持:{slot}',
|
||||
'settings.providers.model1mSupportHint': '「1M」只在该模型端点确实提供 100 万 token 上下文时勾选,勾错会让自动压缩失效。',
|
||||
'settings.providers.model1mSupportTooltip': '声明该模型端点支持 100 万 token 上下文:模型名会追加 [1m],上下文窗口自动填为 1,000,000。若端点实际窗口小于 1M,自动压缩不会触发,请求会撞上服务商的上下文硬限制。取消勾选会恢复预设窗口。',
|
||||
'settings.providers.modelIdPlaceholder': '例如: deepseek-v4-flash',
|
||||
@@ -2171,6 +2175,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
// Settings > Computer Use
|
||||
'settings.tab.computerUse': 'Computer Use',
|
||||
'settings.computerUse.title': 'Computer Use',
|
||||
'settings.computerUse.statusCheckFailed': '无法检查状态。',
|
||||
'settings.computerUse.description': '允许 Claude 截屏、点击、打字并控制你的电脑。需要 Python 3,macOS 上还需要辅助功能权限。',
|
||||
'settings.computerUse.enabledToggle': '启用',
|
||||
'settings.computerUse.enableRiskTitle': '开启 Computer Use?',
|
||||
@@ -2651,7 +2656,9 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.contextReferencesOnly': '已添加 {count} 个引用',
|
||||
'chat.addSelectionToChat': '添加到对话',
|
||||
'chat.imageLoadFailed': '无法加载图片',
|
||||
'chat.imageLoadFailedHint': '文件可能不存在,或没有访问权限。',
|
||||
'chat.imageLoadFailedHint': '文件可能已损坏,或本地服务暂时没有响应。',
|
||||
'chat.imageCountOne': '{count} 张图片',
|
||||
'chat.imageCountOther': '{count} 张图片',
|
||||
'chat.retryImage': '重试图片:{name}',
|
||||
'chat.imageRetrying': '正在重试…',
|
||||
'chat.branchFromHere': 'Fork 一个新对话',
|
||||
@@ -3588,6 +3595,8 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'trace.detail.earlierMessages': '展开更早的 {count} 条消息',
|
||||
'trace.detail.chars': '{count} 字符',
|
||||
'trace.detail.thinking': '思考',
|
||||
'trace.detail.imageBlock': '[图片]',
|
||||
'trace.detail.stack': '调用栈',
|
||||
|
||||
// ─── 应用外壳 ──────────────────────────────────────
|
||||
'app.serverFailed': '本地服务启动失败',
|
||||
@@ -3861,6 +3870,12 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'mermaid.zoomIn': '放大',
|
||||
'mermaid.fit': '适应',
|
||||
'mermaid.fitDiagram': '缩放至适应图表',
|
||||
'mermaid.renderError': 'Mermaid 渲染失败',
|
||||
'mermaid.rendering': '正在渲染图表…',
|
||||
'mermaid.previewTitle': 'Mermaid 图表',
|
||||
'mermaid.previewHint': '用缩放按钮放大图表。在预览区内拖动可平移,也可以用触控板、鼠标滚轮或滚动条;滚动时按住 Ctrl/Command 可缩放。',
|
||||
'mermaid.inlineCanvas': 'Mermaid 内嵌画布',
|
||||
'mermaid.previewCanvas': 'Mermaid 预览画布',
|
||||
'codeViewer.collapse': '收起',
|
||||
'codeViewer.showMoreLines': '显示其余 {count} 行',
|
||||
'common.copy': '复制',
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ApiError } from '../api/client'
|
||||
import { fetchServerImageBlobUrl } from './authedImage'
|
||||
|
||||
/**
|
||||
* Why an image did not load.
|
||||
*
|
||||
* `unavailable`: the local server answered that it will not serve the path — the
|
||||
* file is missing, outside what this client may read, or not an image it serves.
|
||||
* A path pulled from a reply often names nothing at all (a pattern, a web route,
|
||||
* a file cleaned out of /tmp since), so this is no fault and no retry fixes it.
|
||||
*
|
||||
* `failed`: something that should have worked did not — a server fault, a dropped
|
||||
* connection, a refused credential, or bytes that do not decode as an image.
|
||||
*/
|
||||
export type ImageFailure = 'unavailable' | 'failed'
|
||||
|
||||
// What the file routes answer for a path they will not serve: 404 missing, 403
|
||||
// outside the allowed roots, 400 not an image or not a file, 413 too large.
|
||||
const UNAVAILABLE_STATUSES: ReadonlySet<number> = new Set([400, 403, 404, 413])
|
||||
|
||||
function classifyFailure(error: unknown): ImageFailure {
|
||||
return error instanceof ApiError && UNAVAILABLE_STATUSES.has(error.status) ? 'unavailable' : 'failed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Let an `<img>` that a plain request could not load try once more with the app's
|
||||
* credential (see {@link fetchServerImageBlobUrl}).
|
||||
*
|
||||
* Spread `src` and `onError` onto the image. `onFailure` runs only when the
|
||||
* authenticated attempt has also failed — a missing or denied file, or a body that
|
||||
* is not an image — so callers keep their own failure notice for real failures and
|
||||
* never flash it for a request that was merely missing a header.
|
||||
* authenticated attempt has also failed, and says why (see {@link ImageFailure}),
|
||||
* so callers keep their own failure notice for real failures and never flash it
|
||||
* for a request that was merely missing a header.
|
||||
*/
|
||||
export function useAuthedImageFallback(src: string | undefined, onFailure?: () => void, retryWithCredential = false) {
|
||||
export function useAuthedImageFallback(src: string | undefined, onFailure?: (failure: ImageFailure) => void, retryWithCredential = false) {
|
||||
const attempt = useRef({ src, state: 'idle' as 'idle' | 'fetching' | 'resolved' | 'failed' })
|
||||
if (attempt.current.src !== src) attempt.current = { src, state: 'idle' }
|
||||
const current = attempt.current
|
||||
@@ -34,7 +56,9 @@ export function useAuthedImageFallback(src: string | undefined, onFailure?: () =
|
||||
if (attempt.current !== current || current.state === 'fetching' || current.state === 'failed') return
|
||||
if (!src || current.state === 'resolved') {
|
||||
current.state = 'failed'
|
||||
onFailureRef.current?.()
|
||||
// No source names nothing to show. A copy the server did send that still
|
||||
// errors is a file that is there and does not decode.
|
||||
onFailureRef.current?.(src ? 'failed' : 'unavailable')
|
||||
return
|
||||
}
|
||||
current.state = 'fetching'
|
||||
@@ -47,10 +71,10 @@ export function useAuthedImageFallback(src: string | undefined, onFailure?: () =
|
||||
current.state = 'resolved'
|
||||
objectUrls.current.push(url)
|
||||
setResolved({ attempt: current, url })
|
||||
}).catch(() => {
|
||||
}).catch((error: unknown) => {
|
||||
if (alive.current && attempt.current === current) {
|
||||
current.state = 'failed'
|
||||
onFailureRef.current?.()
|
||||
onFailureRef.current?.(classifyFailure(error))
|
||||
}
|
||||
})
|
||||
}, [src, current, retryWithCredential])
|
||||
|
||||
@@ -776,7 +776,7 @@ export function ActivitySettings() {
|
||||
<div className="relative h-16 w-16 overflow-hidden rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-card)]">
|
||||
<img
|
||||
src={avatarSrc}
|
||||
alt={`${profile.displayName} avatar`}
|
||||
alt={t('settings.activity.avatarAlt', { name: profile.displayName })}
|
||||
className={avatarClassName}
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = DEFAULT_AVATAR_SRC
|
||||
|
||||
@@ -284,7 +284,7 @@ export function ComputerUseSettings() {
|
||||
{checkState === 'error' ? (
|
||||
<ErrorState
|
||||
size="lg"
|
||||
title="Failed to check status."
|
||||
title={t('settings.computerUse.statusCheckFailed')}
|
||||
retryLabel={t('common.retry')}
|
||||
onRetry={fetchStatus}
|
||||
/>
|
||||
@@ -428,7 +428,7 @@ export function ComputerUseSettings() {
|
||||
) : checkState === 'error' ? (
|
||||
<ErrorState
|
||||
size="lg"
|
||||
title="Failed to check status."
|
||||
title={t('settings.computerUse.statusCheckFailed')}
|
||||
retryLabel={t('common.retry')}
|
||||
onRetry={fetchStatus}
|
||||
/>
|
||||
@@ -742,7 +742,7 @@ function NativeComputerUse({
|
||||
{configErrorNotice}
|
||||
<ErrorState
|
||||
size="lg"
|
||||
title="Failed to check status."
|
||||
title={t('settings.computerUse.statusCheckFailed')}
|
||||
retryLabel={t('common.retry')}
|
||||
onRetry={onRecheck}
|
||||
tone="strong"
|
||||
|
||||
@@ -1904,7 +1904,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode
|
||||
type="checkbox"
|
||||
checked={model1mSupport[slot]}
|
||||
onChange={(e) => handleModel1mSupportChange(slot, e.target.checked)}
|
||||
aria-label={`1M support: ${slot}`}
|
||||
aria-label={t('settings.providers.model1mSupportAria', { slot })}
|
||||
className="h-3.5 w-3.5 rounded border-[var(--color-border)] text-[var(--color-brand)] accent-[var(--color-brand)] focus:ring-[var(--color-brand)]"
|
||||
/>
|
||||
<span>{t('settings.providers.model1mSupportShort')}</span>
|
||||
|
||||
@@ -376,6 +376,22 @@ describe('filesystem API', () => {
|
||||
expect(Buffer.from(await res.arrayBuffer()).equals(PNG)).toBe(true)
|
||||
})
|
||||
|
||||
it('answers 404 for a picture missing from an allowed root', async () => {
|
||||
// The chat reads 400/403/404 as "not there to show" and leaves the picture out
|
||||
// quietly; any other answer is an error it offers to retry. A file that is
|
||||
// simply missing — cleaned out of /tmp since the reply named it — stays a 404.
|
||||
const dir = fs.realpathSync(await fsp.mkdtemp(path.join(os.tmpdir(), 'claude-filesystem-test-')))
|
||||
cleanupDirs.add(dir)
|
||||
registerFilesystemAccessRoot(dir)
|
||||
|
||||
const res = await handleFilesystemRoute(
|
||||
'/api/filesystem/file',
|
||||
makeUrl('/api/filesystem/file', { path: path.join(dir, 'cleaned', 'chart.png') }),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('still keeps a home-relative path inside the allowed roots', async () => {
|
||||
// A picture that really exists, outside $HOME, the temp directories and every
|
||||
// registered root, reached by climbing out of the home directory. A missing
|
||||
|
||||
Reference in New Issue
Block a user