fix(desktop): keep malformed AskUserQuestion input from crashing the chat

A tool call the server rejected as invalid stays in the transcript, and its
card is rebuilt from that input on every replay. An option description that
came back as an object threw React #31 and replaced the whole app with the
error page, again on every launch because the open tab is restored.

AskUserQuestion now renders only the string fields it can show, and each
transcript row sits in its own error boundary so a bad record no longer takes
the whole window down.

Refs #1400
This commit is contained in:
程序员阿江(Relakkes)
2026-09-29 15:58:44 +08:00
parent 096e87619d
commit 3556e9e619
11 changed files with 539 additions and 87 deletions
@@ -966,4 +966,150 @@ describe('AskUserQuestion', () => {
expect(storedDraft()).toBeUndefined()
})
})
// Issue #1400. The input is whatever the model emitted. The server validates it and
// answers a bad call with InputValidationError, but the transcript keeps the
// tool_use as sent, so a card is rebuilt from it on every replay. One provider's
// tool-call parser turned "`rm <path>`" inside a description into an object
// ({ $text, path }); rendering that as a child threw React #31, which took the
// whole app down — again on every launch, since the open tab is restored.
describe('input the model got wrong', () => {
// The exact shape from the report: text around an inline <path> became an object.
const BROKEN_TEXT = {
path: '`(一次一个,不用 wildcard)。CLAUDE.md 禁止 wildcard 删多个文件。',
$text: '我会逐个执行 `rm ',
}
const INVALID_INPUT_RESULT =
'<tool_use_error>InputValidationError: AskUserQuestion failed due to the following issue:\n' +
'The parameter `questions[0].options[0].description` type is expected as `string` but provided as `object`</tool_use_error>'
function singleQuestion(question: Record<string, unknown>) {
return { questions: [{ question: 'Pick one?', ...question }] }
}
it('renders the rest of a question whose option description is an object', () => {
render(<AskUserQuestion toolUseId="tool-1" input={singleQuestion({
question: 'Delete these 5 files?',
header: 'Confirm',
options: [
{ label: 'Delete all 5', description: BROKEN_TEXT },
{ label: 'Wait', description: 'Back up first.' },
],
})} />)
expect(screen.getByText('Delete these 5 files?')).toBeTruthy()
// The broken description is dropped with nothing rendered in its place.
expect(screen.getByRole('button', { name: /^Delete all 5$/ })).toBeTruthy()
// Its sound sibling keeps its own.
expect(screen.getByText('Back up first.')).toBeTruthy()
})
it('still answers a question that has a broken option, handing the original input back', () => {
const input = singleQuestion({
question: 'Delete these 5 files?',
options: [{ label: 'Delete all 5', description: BROKEN_TEXT }, { label: 'Wait' }],
})
render(<AskUserQuestion toolUseId="tool-1" input={input} />)
fireEvent.click(screen.getByRole('button', { name: /^Wait$/ }))
fireEvent.click(screen.getByRole('button', { name: /submit/i }))
// Only what is shown is cleaned up; the answer travels with the input untouched.
expect(sendMock).toHaveBeenCalledWith(ACTIVE_TAB, {
type: 'permission_response',
requestId: 'perm-1',
allowed: true,
updatedInput: { ...input, answers: { 'Delete these 5 files?': 'Wait' } },
})
})
it('shows a failed call from history together with its error result', () => {
render(<AskUserQuestion
toolUseId="tool-failed"
input={singleQuestion({
question: 'Delete these 5 files?',
options: [{ label: 'Delete all 5', description: BROKEN_TEXT }, { label: 'Wait' }],
})}
result={INVALID_INPUT_RESULT}
/>)
expect(screen.getByText('Delete these 5 files?')).toBeTruthy()
expect(screen.getByText(/InputValidationError/)).toBeTruthy()
})
it('renders nothing for a question whose text is an object', () => {
const { container } = render(<AskUserQuestion toolUseId="tool-1" input={singleQuestion({
question: BROKEN_TEXT,
options: [{ label: 'Yes' }],
})} />)
expect(container.textContent).toBe('')
})
it('drops an option whose label is an object and keeps its siblings', () => {
render(<AskUserQuestion toolUseId="tool-1" input={singleQuestion({
options: [{ label: BROKEN_TEXT, description: 'goes with its label' }, { label: 'B' }],
})} />)
expect(screen.getByRole('button', { name: /^B$/ })).toBeTruthy()
expect(screen.queryByText('goes with its label')).toBeNull()
})
it.each([
['a string', 'A or B'],
['an object', { A: 'first', B: 'second' }],
])('renders the question when options is %s instead of a list', (_shape, options) => {
render(<AskUserQuestion toolUseId="tool-1" input={singleQuestion({ options })} />)
expect(screen.getByText('Pick one?')).toBeTruthy()
expect(screen.getByRole('textbox')).toBeTruthy()
})
it('skips null and non-object entries among the options', () => {
render(<AskUserQuestion toolUseId="tool-1" input={singleQuestion({
options: [null, 'stray text', 7, { label: 'Real' }],
})} />)
expect(screen.getByRole('button', { name: /^Real$/ })).toBeTruthy()
expect(screen.queryByText('stray text')).toBeNull()
})
it('skips entries of questions that are not questions', () => {
render(<AskUserQuestion toolUseId="tool-1" input={{
questions: [null, 'stray text', { question: 'Real?', options: [{ label: 'Yes' }] }],
}} />)
expect(screen.getByText('Real?')).toBeTruthy()
// One question is left, so there is no tab strip to number.
expect(screen.queryByRole('button', { name: 'Q1' })).toBeNull()
})
it('numbers the tab of a question whose header is not text', () => {
render(<AskUserQuestion toolUseId="tool-1" input={{
questions: [
{ question: 'First?', header: BROKEN_TEXT, options: [{ label: 'A' }] },
{ question: 'Second?', header: 'Sound', options: [{ label: 'B' }] },
],
}} />)
expect(screen.getByRole('button', { name: 'Q1' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Sound' })).toBeTruthy()
})
// chatStore rebuilds tool_use messages under a stable id, so a mounted card can be
// handed a repaired input after it was handed a broken one.
it('recovers when a broken input is replaced by a sound one while mounted', () => {
const { container, rerender } = render(<AskUserQuestion toolUseId="tool-1" input={singleQuestion({
question: BROKEN_TEXT,
})} />)
expect(container.textContent).toBe('')
rerender(<AskUserQuestion toolUseId="tool-1" input={singleQuestion({
question: 'Ship it?',
options: [{ label: 'Yes' }],
})} />)
expect(screen.getByText('Ship it?')).toBeTruthy()
})
})
})
+37 -23
View File
@@ -24,14 +24,6 @@ type Question = {
multiSelect?: boolean
}
type AskUserInput = {
questions?: Question[]
question?: string
header?: string
options?: QuestionOption[]
multiSelect?: boolean
}
type Props = {
sessionId?: string | null
toolUseId: string
@@ -45,29 +37,51 @@ type Props = {
supersededByUserMessage?: boolean
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
// The input is whatever the model emitted, and the transcript keeps it even when the
// server rejected the call as invalid — so a card is rebuilt from it on every replay
// (issue #1400: an option description that came back as an object threw React #31 and
// took the whole app down, again on every launch). Only string fields are trusted,
// because a non-string child throws while rendering; anything else is left out rather
// than shown. That cleanup is display-only: the answer still travels with the
// original input (see handleSubmit).
function toOption(value: unknown): QuestionOption | null {
if (!isRecord(value) || typeof value.label !== 'string') return null
return typeof value.description === 'string'
? { label: value.label, description: value.description }
: { label: value.label }
}
function toQuestion(value: unknown): Question | null {
if (!isRecord(value) || typeof value.question !== 'string') return null
return {
question: value.question,
header: typeof value.header === 'string' ? value.header : undefined,
options: Array.isArray(value.options)
? value.options.flatMap((option) => toOption(option) ?? [])
: undefined,
multiSelect: value.multiSelect === true,
}
}
/**
* Parse the AskUserQuestion input which may come in different shapes.
*/
function parseInput(input: unknown): Question[] {
if (!input || typeof input !== 'object') return []
const obj = input as AskUserInput
if (!isRecord(input)) return []
// Shape 1: { questions: [...] }
if (Array.isArray(obj.questions)) {
return obj.questions
if (Array.isArray(input.questions)) {
return input.questions.flatMap((question) => toQuestion(question) ?? [])
}
// Shape 2: { question: "...", options: [...] }
if (typeof obj.question === 'string') {
return [{
question: obj.question,
header: obj.header,
options: obj.options,
multiSelect: obj.multiSelect,
}]
}
return []
const single = toQuestion(input)
return single ? [single] : []
}
type QuestionSelections = Record<number, string[]>
@@ -100,7 +114,7 @@ export function AskUserQuestion({
const sessionConnectionState = useChatStore((s) =>
targetSessionId ? s.sessions[targetSessionId]?.connectionState : undefined)
const t = useTranslation()
const questions = parseInput(input)
const questions = useMemo(() => parseInput(input), [input])
const inputObject = (input && typeof input === 'object') ? input as Record<string, unknown> : {}
// Read once instead of subscribing: this card writes the draft on every
// change, and a subscription would feed its own writes back as re-renders.
@@ -0,0 +1,163 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
const { reportMock } = vi.hoisted(() => ({
reportMock: vi.fn(async () => undefined),
}))
vi.mock('../../lib/diagnosticsCapture', async (importOriginal) => ({
...(await importOriginal<typeof import('../../lib/diagnosticsCapture')>()),
reportReactError: reportMock,
}))
// A card that fails the way an unforeseen stored record would. What breaks a real one
// is beside the point here; this file is about where the failure is allowed to land.
vi.mock('./AskUserQuestion', () => ({
AskUserQuestion: ({ toolUseId }: { toolUseId: string }) => {
if (toolUseId === 'tool-poisoned') throw new Error('card exploded')
return <div>question card {toolUseId}</div>
},
}))
import { MessageList } from './MessageList'
import { sessionsApi } from '../../api/sessions'
import { useChatStore, type PerSessionState } from '../../stores/chatStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTabStore } from '../../stores/tabStore'
import { useTeamStore } from '../../stores/teamStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
import { useWorkspaceStore } from '../../stores/workspaceStore'
import type { UIMessage } from '../../types/chat'
const ACTIVE_TAB = 'active-tab'
function makeSessionState(messages: UIMessage[]): PerSessionState {
return {
messages,
chatState: 'idle',
connectionState: 'connected',
historyStatus: 'ready',
historyHydrated: true,
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
streamingResponseChars: 0,
elapsedSeconds: 0,
statusVerb: '',
apiRetry: null,
slashCommands: [],
agentTaskNotifications: {},
elapsedTimer: null,
composerPrefill: null,
}
}
function askMessage(toolUseId: string, timestamp: number): UIMessage {
return {
id: `ask-${toolUseId}`,
type: 'tool_use',
toolName: 'AskUserQuestion',
toolUseId,
input: { questions: [{ question: 'Which scope?', options: [{ label: 'A' }, { label: 'B' }] }] },
timestamp,
}
}
describe('MessageList item containment', () => {
beforeEach(() => {
vi.restoreAllMocks()
reportMock.mockClear()
// React logs every caught render error; keep the output readable.
vi.spyOn(console, 'error').mockImplementation(() => {})
useSettingsStore.setState({ locale: 'en' })
useTabStore.setState({
activeTabId: ACTIVE_TAB,
tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session' as const, status: 'idle' }],
})
useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null })
useTeamStore.getState().clearTeam()
useWorkspaceChatContextStore.setState(useWorkspaceChatContextStore.getInitialState(), true)
useWorkspaceStore.setState(useWorkspaceStore.getInitialState(), true)
vi.spyOn(sessionsApi, 'getTurnCheckpoints').mockImplementation(() => new Promise(() => {}))
vi.spyOn(sessionsApi, 'getWorkspaceStatus').mockResolvedValue({
state: 'ok',
workDir: '/tmp/example-project',
repoName: 'example-project',
branch: null,
isGitRepo: false,
changedFiles: [],
})
})
// Issue #1400: one poisoned record in a saved transcript used to replace the whole
// app with the root error page, again on every launch.
it('keeps a row that fails to render from taking the transcript down', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState([
{ id: 'user-1', type: 'user_text', content: 'clean up the temp files', timestamp: 1 },
{ id: 'assistant-1', type: 'assistant_text', content: 'reply before the bad card', timestamp: 2 },
askMessage('tool-poisoned', 3),
{ id: 'assistant-2', type: 'assistant_text', content: 'reply after the bad card', timestamp: 4 },
]),
},
})
render(<MessageList />)
expect(screen.getByText('clean up the temp files')).toBeTruthy()
expect(screen.getByText('reply before the bad card')).toBeTruthy()
expect(screen.getByText('reply after the bad card')).toBeTruthy()
expect(screen.getByText(/This item couldn't be displayed/)).toBeTruthy()
expect(reportMock).toHaveBeenCalledTimes(1)
})
it('leaves healthy cards in the same transcript rendered', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState([
// Answered, so it stays in the history; only the latest unresolved question
// is ever shown, which would hide it otherwise.
askMessage('tool-fine', 1),
{
id: 'result-fine',
type: 'tool_result',
toolUseId: 'tool-fine',
content: { answers: { 'Which scope?': 'A' } },
isError: false,
timestamp: 2,
},
askMessage('tool-poisoned', 3),
]),
},
})
render(<MessageList />)
expect(screen.getByText('question card tool-fine')).toBeTruthy()
expect(screen.getAllByText(/This item couldn't be displayed/)).toHaveLength(1)
})
it('shows no notice and reports nothing for a transcript that renders', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState([
{ id: 'assistant-1', type: 'assistant_text', content: 'all good here', timestamp: 1 },
askMessage('tool-fine', 2),
]),
},
})
render(<MessageList />)
expect(screen.getByText('all good here')).toBeTruthy()
expect(screen.queryByText(/couldn't be displayed/)).toBeNull()
expect(reportMock).not.toHaveBeenCalled()
})
})
+67 -64
View File
@@ -28,6 +28,7 @@ import type { ActivityStep } from './activityGroupModel'
import { ToolResultBlock } from './ToolResultBlock'
import { PermissionDialog } from './PermissionDialog'
import { AskUserQuestion } from './AskUserQuestion'
import { RenderItemBoundary } from './RenderItemBoundary'
import { StreamingIndicator } from './StreamingIndicator'
import { InlineTaskSummary } from './InlineTaskSummary'
import { CurrentTurnChangeCard } from './CurrentTurnChangeCard'
@@ -3555,70 +3556,72 @@ export function MessageList({
return (
<>
{item.kind === 'tool_group' ? (
<ToolCallGroup
sessionId={resolvedSessionId}
onOpenAgentRun={onOpenAgentRun}
resolveAgentActivityTarget={resolveAgentActivityTarget}
toolCalls={item.toolCalls}
steps={item.steps}
resultMap={toolResultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
agentTaskStatuses={agentTaskStatuses}
activeThinkingId={activeThinkingId}
isStreaming={
chatState === 'tool_executing' &&
item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId))
}
// Only the tail of a live turn can still grow. Everything above it
// is finished, whatever any individual tool's state looks like this
// instant — which is why this, and not `isStreaming`, decides
// whether a run stands open.
isLive={chatState !== 'idle' && index === renderItems.length - 1 && !hasTrailingStreamingItem}
disclosureKey={getRenderItemKey(item)}
/>
) : item.kind === 'team_card' ? (
resolvedSessionId ? (() => {
const cardSnapshot = snapshotForTeamCard(teamSnapshot, item)
const fallbackPhase = item.endedAt !== undefined || teamTaskWindows.some((window) => (
item.startedAt >= window.startedAt &&
window.endedAt !== undefined &&
item.startedAt <= window.endedAt
)) ? 'completed' : 'forming'
return (
<AgentTeamsInlineCard
snapshot={cardSnapshot}
teamName={item.teamName}
fallbackPhase={fallbackPhase}
phaseOverride={item.endedAt !== undefined ? 'completed' : undefined}
onOpen={cardSnapshot
? () => openTeamWorkbench(resolvedSessionId, cardSnapshot.team.name)
: undefined}
>
<TeamCoordinationAudit toolCalls={item.coordinationToolCalls} />
</AgentTeamsInlineCard>
)
})() : null
) : (
<MessageBlock
sessionId={resolvedSessionId}
message={item.message}
team={memberSessionTeam ?? undefined}
activeThinkingId={activeThinkingId}
agentTaskNotifications={agentTaskNotifications}
toolResult={
item.message.type === 'tool_use'
? toolResultByToolUseId.get(item.message.toolUseId) ?? null
: null
}
branchAction={branchActionByMessageId.get(item.message.id)}
turnChangedFiles={changedFilesByRenderIndex.get(index)}
isTurnOutputOwner={turnOutputOwnerIndexes.has(index)}
turnCompletion={turnCompletionByMessageId.get(item.message.id)}
supersededAskUserQuestionIds={supersededAskUserQuestionIds}
/>
)}
<RenderItemBoundary>
{item.kind === 'tool_group' ? (
<ToolCallGroup
sessionId={resolvedSessionId}
onOpenAgentRun={onOpenAgentRun}
resolveAgentActivityTarget={resolveAgentActivityTarget}
toolCalls={item.toolCalls}
steps={item.steps}
resultMap={toolResultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotifications={agentTaskNotifications}
agentTaskStatuses={agentTaskStatuses}
activeThinkingId={activeThinkingId}
isStreaming={
chatState === 'tool_executing' &&
item.toolCalls.some((tc) => !toolResultMap.has(tc.toolUseId))
}
// Only the tail of a live turn can still grow. Everything above it
// is finished, whatever any individual tool's state looks like this
// instant — which is why this, and not `isStreaming`, decides
// whether a run stands open.
isLive={chatState !== 'idle' && index === renderItems.length - 1 && !hasTrailingStreamingItem}
disclosureKey={getRenderItemKey(item)}
/>
) : item.kind === 'team_card' ? (
resolvedSessionId ? (() => {
const cardSnapshot = snapshotForTeamCard(teamSnapshot, item)
const fallbackPhase = item.endedAt !== undefined || teamTaskWindows.some((window) => (
item.startedAt >= window.startedAt &&
window.endedAt !== undefined &&
item.startedAt <= window.endedAt
)) ? 'completed' : 'forming'
return (
<AgentTeamsInlineCard
snapshot={cardSnapshot}
teamName={item.teamName}
fallbackPhase={fallbackPhase}
phaseOverride={item.endedAt !== undefined ? 'completed' : undefined}
onOpen={cardSnapshot
? () => openTeamWorkbench(resolvedSessionId, cardSnapshot.team.name)
: undefined}
>
<TeamCoordinationAudit toolCalls={item.coordinationToolCalls} />
</AgentTeamsInlineCard>
)
})() : null
) : (
<MessageBlock
sessionId={resolvedSessionId}
message={item.message}
team={memberSessionTeam ?? undefined}
activeThinkingId={activeThinkingId}
agentTaskNotifications={agentTaskNotifications}
toolResult={
item.message.type === 'tool_use'
? toolResultByToolUseId.get(item.message.toolUseId) ?? null
: null
}
branchAction={branchActionByMessageId.get(item.message.id)}
turnChangedFiles={changedFilesByRenderIndex.get(index)}
isTurnOutputOwner={turnOutputOwnerIndexes.has(index)}
turnCompletion={turnCompletionByMessageId.get(item.message.id)}
supersededAskUserQuestionIds={supersededAskUserQuestionIds}
/>
)}
</RenderItemBoundary>
{resolvedSessionId && cardsForItem.map((card) => {
@@ -0,0 +1,73 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
const { reportMock } = vi.hoisted(() => ({
reportMock: vi.fn(async () => undefined),
}))
vi.mock('../../lib/diagnosticsCapture', () => ({
reportReactError: reportMock,
}))
import { RenderItemBoundary } from './RenderItemBoundary'
import { useSettingsStore } from '../../stores/settingsStore'
function Explodes(): never {
throw new Error('row exploded')
}
describe('RenderItemBoundary', () => {
beforeEach(() => {
reportMock.mockClear()
useSettingsStore.setState({ locale: 'en' })
// React logs every caught render error; keep the output readable.
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('renders its children untouched when nothing throws', () => {
render(<RenderItemBoundary><span>fine</span></RenderItemBoundary>)
expect(screen.getByText('fine')).toBeTruthy()
expect(screen.queryByText(/couldn't be displayed/)).toBeNull()
expect(reportMock).not.toHaveBeenCalled()
})
// The point of the boundary: the failure stays where it happened.
it('replaces only the item that throws and leaves its neighbours alone', () => {
render(
<div>
<RenderItemBoundary><span>before</span></RenderItemBoundary>
<RenderItemBoundary><Explodes /></RenderItemBoundary>
<RenderItemBoundary><span>after</span></RenderItemBoundary>
</div>,
)
expect(screen.getByText('before')).toBeTruthy()
expect(screen.getByText('after')).toBeTruthy()
expect(screen.getByText(/couldn't be displayed/)).toBeTruthy()
})
it('still records the error in Diagnostics, with the component stack', () => {
render(<RenderItemBoundary><Explodes /></RenderItemBoundary>)
expect(reportMock).toHaveBeenCalledTimes(1)
const [error, info] = reportMock.mock.calls[0] as unknown as [Error, { componentStack: string }]
expect(error.message).toBe('row exploded')
expect(info.componentStack).toContain('Explodes')
})
it('does not report a failed item again when its parent re-renders', () => {
const { rerender } = render(<RenderItemBoundary><Explodes /></RenderItemBoundary>)
expect(reportMock).toHaveBeenCalledTimes(1)
rerender(<RenderItemBoundary><Explodes /></RenderItemBoundary>)
rerender(<RenderItemBoundary><Explodes /></RenderItemBoundary>)
expect(reportMock).toHaveBeenCalledTimes(1)
expect(screen.getByText(/couldn't be displayed/)).toBeTruthy()
})
})
@@ -0,0 +1,48 @@
import React from 'react'
import { TriangleAlert } from 'lucide-react'
import { t } from '../../i18n'
import { reportReactError } from '../../lib/diagnosticsCapture'
type Props = {
children: React.ReactNode
}
type State = {
failed: boolean
}
/**
* Keeps one transcript item that fails to render from taking the whole app with it.
*
* The root ErrorBoundary replaces everything, and a transcript is rebuilt from saved
* history — so a single bad record (issue #1400: an AskUserQuestion input the model
* got wrong) crashed the app again on every launch, because the open tab is restored.
* Here the failure stays in its row: the rest of the conversation, the sidebar and the
* composer keep working, and the error still goes to Diagnostics.
*
* There is deliberately no automatic retry. A stored record fails the same way every
* time, and retrying on each render would report it on every update of a live list.
* The row is tried again whenever it is mounted afresh (switching session, reloading).
*/
export class RenderItemBoundary extends React.Component<Props, State> {
state: State = { failed: false }
static getDerivedStateFromError(): State {
return { failed: true }
}
componentDidCatch(error: unknown, errorInfo: React.ErrorInfo) {
void reportReactError(error, errorInfo)
}
render() {
if (!this.state.failed) return this.props.children
return (
<div className="mb-3 flex items-center gap-2 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-2 text-xs text-[var(--color-text-tertiary)]">
<TriangleAlert aria-hidden size={14} strokeWidth={1.75} className="shrink-0" />
<span>{t('errorBoundary.item')}</span>
</div>
)
}
}
+1
View File
@@ -1320,6 +1320,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'settings.diagnostics.doctorNoKeys': 'None',
'errorBoundary.title': 'Something went wrong.',
'errorBoundary.description': 'The error was recorded in Diagnostics.',
'errorBoundary.item': "This item couldn't be displayed. The error was recorded in Diagnostics.",
// Settings > Claude Official Login
'settings.claudeOfficialLogin.intro': 'Using official Claude models requires signing in to your Claude.ai account. Click the button below to open the official Claude login page in your browser; you\'ll be returned here after authorizing.',
+1
View File
@@ -1321,6 +1321,7 @@ export const jp: Record<TranslationKey, string> = {
'settings.diagnostics.doctorNoKeys': 'なし',
'errorBoundary.title': '問題が発生しました。',
'errorBoundary.description': 'エラーは診断に記録されました。',
'errorBoundary.item': 'この項目は表示できませんでした。エラーは診断に記録されました。',
// Settings > Claude Official Login
'settings.claudeOfficialLogin.intro': '公式 Claude モデルを使用するには、Claude.ai アカウントにサインインする必要があります。下のボタンをクリックすると、ブラウザで公式 Claude ログインページが開きます。承認後、ここに戻ります。',
+1
View File
@@ -1323,6 +1323,7 @@ export const kr: Record<TranslationKey, string> = {
'settings.diagnostics.doctorNoKeys': '없음',
'errorBoundary.title': '문제가 발생했습니다.',
'errorBoundary.description': '오류가 진단에 기록되었습니다.',
'errorBoundary.item': '이 항목을 표시할 수 없습니다. 오류가 진단에 기록되었습니다.',
// Settings > Claude Official Login
'settings.claudeOfficialLogin.intro': '공식 Claude 모델을 사용하려면 Claude.ai 계정에 로그인해야 합니다. 아래 버튼을 클릭하면 브라우저에서 공식 Claude 로그인 페이지가 열립니다. 승인 후 이곳으로 돌아옵니다.',
+1
View File
@@ -1320,6 +1320,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.diagnostics.doctorNoKeys': '無',
'errorBoundary.title': '出現異常。',
'errorBoundary.description': '錯誤已記錄到診斷日誌。',
'errorBoundary.item': '此則內容無法顯示。錯誤已記錄到診斷日誌。',
// Settings > Claude Official Login
'settings.claudeOfficialLogin.intro': '使用官方 Claude 模型需要登入你的 Claude.ai 賬號。點選下方按鈕,瀏覽器會開啟 Claude 官方登入頁面,授權後自動回到這裡。',
+1
View File
@@ -1319,6 +1319,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.diagnostics.doctorNoKeys': '无',
'errorBoundary.title': '出现异常。',
'errorBoundary.description': '错误已记录到诊断日志。',
'errorBoundary.item': '此条内容无法显示。错误已记录到诊断日志。',
// Settings > Claude Official Login
'settings.claudeOfficialLogin.intro': '使用官方 Claude 模型需要登录你的 Claude.ai 账号。点击下方按钮,浏览器会打开 Claude 官方登录页面,授权后自动回到这里。',