diff --git a/apps/ai-game-creator-shell/src/components/error-report/ErrorReportNotice.tsx b/apps/ai-game-creator-shell/src/components/error-report/ErrorReportNotice.tsx index 676cece87..35eb4221c 100644 --- a/apps/ai-game-creator-shell/src/components/error-report/ErrorReportNotice.tsx +++ b/apps/ai-game-creator-shell/src/components/error-report/ErrorReportNotice.tsx @@ -5,6 +5,7 @@ import { type ClientErrorEvent, getPendingClientErrorEvents, subscribeClientErrorEvents, + visibleClientErrorMessage, } from '../../services/errorReporting'; import { ErrorReportDialog } from './ErrorReportDialog'; @@ -15,6 +16,7 @@ export function ErrorReportNotice() { const [reportFallbackEvents, setReportFallbackEvents] = useState< ClientErrorEvent[] >([]); + const [loadError, setLoadError] = useState(''); const [reportOpen, setReportOpen] = useState(false); const requestId = useRef(0); @@ -30,13 +32,20 @@ export function ErrorReportNotice() { try { const events = await getPendingClientErrorEvents(); if (currentRequest !== requestId.current) return; + setLoadError(''); setNotificationEvents(events); - } catch { + } catch (error) { if ( currentRequest !== requestId.current || attempt >= retryDelays.length - ) + ) { + if (currentRequest === requestId.current) { + setLoadError( + `项目诊断读取失败:${visibleClientErrorMessage(error)}`, + ); + } return; + } retryTimer.current = window.setTimeout(() => { retryTimer.current = null; void run(attempt + 1); @@ -110,6 +119,24 @@ export function ErrorReportNotice() { ) : null} + {loadError && notificationEvents.length === 0 ? ( + + ) : null} ({ vi.mock('../src/services/errorReporting', () => ({ getPendingClientErrorEvents: mockState.getPending, + visibleClientErrorMessage: (error: unknown) => + error instanceof Error ? error.message : String(error), subscribeClientErrorEvents: (listener: () => void) => { mockState.listeners.push(listener); return () => { @@ -95,4 +98,20 @@ describe('ErrorReportNotice', () => { expect(await screen.findByTestId('error-report-dialog')).toBeTruthy(); expect(dialogState.fallbackEvents).toEqual([event]); }); + + it('项目诊断快照读取失败时显示具体错误正文', async () => { + mockState.getPending.mockRejectedValue(new Error('IPC channel closed')); + vi.useFakeTimers(); + try { + render(); + await act(async () => { + await vi.runAllTimersAsync(); + }); + expect( + screen.getByText('项目诊断读取失败:IPC channel closed'), + ).toBeTruthy(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index d4cc45d42..24bcd8eec 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -137,6 +137,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - `agc_web_search` 的上游非 2xx 也属于用户可见的受控工具失败:MCP 结果必须保留 HTTP 状态与有界脱敏正文,不能只返回状态码。 - 跨项目活动回合快照读取失败即使保留旧快照,也必须把最近一次安全正文一并呈现;“读不到不等于没有在跑”不能变成“读不到所以没有原因”。 - 项目打开前的权限策略读取属于 IPC 边界,失败不得静默中止加载;必须保留安全正文并标明是策略读取失败。 +- 错误报告通知自身的快照读取失败也不能被通知组件的重试逻辑吞掉;重试耗尽后必须显示安全正文和再次读取入口。 ## 后果与边界 diff --git a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md index ce011c3b6..2791df445 100644 --- a/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【里程碑】AGC错误具体文本展示-2026-10-04.md @@ -58,3 +58,4 @@ DirectProject 回合失败在确认不是客户端内部不可归类故障时, - 受控联网搜索的上游非 2xx 响应原来只返回 HTTP 状态;现保留有界、脱敏的服务端正文,错误码/限流原因不再丢失。 - 窗口标题栏的 Direct 活动回合快照读取失败原来只给“读取失败”标志并保留旧快照;现额外保留脱敏后的 IPC/读取正文,面板不会把真实原因吞掉。 - 项目打开时读取 `conversation.read` 权限策略的 IPC 失败原来静默 return;现把脱敏错误正文写入项目状态和聊天错误提示。 +- 错误报告通知读取本地待报告快照失败原来只重试后静默消失;现显示“项目诊断读取失败”及安全正文,并提供重试入口。