UI编辑器一批细节优化 #278

Merged
kdletters merged 44 commits from fix/ui-editor-minor-adjusts into master 2026-09-08 11:56:36 +08:00
5 changed files with 193 additions and 27 deletions
Showing only changes of commit a08b224590 - Show all commits
@@ -4,6 +4,7 @@ import {
ackClientErrorEventsWithRetry,
type ClientErrorEvent,
type DiagnosticLogFile,
getPendingClientErrorEvents,
getStableErrorReportSubmissionId,
readApplicationDiagnosticLogs,
submitErrorReportBatch,
@@ -12,10 +13,12 @@ import { ThemedModal } from '../modal/ThemedModal';
type ErrorReportDialogProps = {
open: boolean;
events: ClientErrorEvent[];
onClose: () => void;
fallbackEvents?: ClientErrorEvent[];
};
const emptyFallbackEvents: ClientErrorEvent[] = [];
function diagnosticLogLabel(
logsReady: boolean,
logsError: boolean,
@@ -28,9 +31,12 @@ function diagnosticLogLabel(
export function ErrorReportDialog({
open,
events,
onClose,
fallbackEvents = emptyFallbackEvents,
}: ErrorReportDialogProps) {
const [events, setEvents] = useState<ClientErrorEvent[]>([]);
const [eventsReady, setEventsReady] = useState(false);
const [eventsError, setEventsError] = useState(false);
const [selectedFingerprints, setSelectedFingerprints] = useState<Set<string>>(
new Set(),
);
@@ -45,29 +51,59 @@ export function ErrorReportDialog({
useEffect(() => {
if (!open) return;
setSelectedFingerprints(
new Set(events.slice(0, 1).map((event) => event.fingerprint)),
);
let disposed = false;
setEvents([]);
setEventsReady(false);
setEventsError(false);
setSelectedFingerprints(new Set());
setStatus('');
setLogsReady(false);
setLogsError(false);
void getPendingClientErrorEvents()
.then((nextEvents) => {
if (disposed) return;
setEvents(nextEvents);
setSelectedFingerprints(
new Set(nextEvents.map((event) => event.fingerprint)),
);
setEventsReady(true);
})
.catch(() => {
if (disposed) return;
if (fallbackEvents.length) {
setEvents(fallbackEvents);
setSelectedFingerprints(
new Set(fallbackEvents.map((event) => event.fingerprint)),
);
setEventsReady(true);
setStatus('最新错误读取失败,已使用打开通知时的快照');
return;
}
setEventsError(true);
setEventsReady(true);
});
void readApplicationDiagnosticLogs()
.then((nextLogs) => {
if (disposed) return;
setLogs(nextLogs);
setLogsReady(true);
})
.catch(() => {
if (disposed) return;
setLogs([]);
setLogsError(true);
setLogsReady(true);
});
}, [events, open]);
return () => {
disposed = true;
};
}, [fallbackEvents, open]);
const submit = useCallback(async () => {
const selectedEvents = events.filter((event) =>
selectedFingerprints.has(event.fingerprint),
);
if (!selectedEvents.length || busy || !logsReady) return;
if (!selectedEvents.length || busy || !eventsReady || !logsReady) return;
if (includeLogs && logsError) return;
setBusy(true);
setStatus('正在提交…');
@@ -92,6 +128,7 @@ export function ErrorReportDialog({
}, [
busy,
description,
eventsReady,
events,
includeLogs,
logs,
@@ -124,7 +161,11 @@ export function ErrorReportDialog({
<div className="my-5 grid gap-4">
<section>
<strong>{events.length}</strong>
{events.length ? (
{!eventsReady && <p></p>}
{eventsReady && eventsError && (
<p role="alert"></p>
)}
{eventsReady && !eventsError && events.length > 0 && (
<ul className="m-0 mt-2 grid list-none gap-2 p-0">
{events.map((event) => (
<li
@@ -154,7 +195,8 @@ export function ErrorReportDialog({
</li>
))}
</ul>
) : (
)}
{eventsReady && !eventsError && !events.length && (
<p></p>
)}
</section>
@@ -191,11 +233,12 @@ export function ErrorReportDialog({
</button>
<button
className="cursor-pointer rounded-[9px] border-0 bg-[var(--platform-button-primary-fill)] px-3.5 py-[9px] text-[var(--platform-button-primary-text)]"
className="cursor-pointer rounded-[9px] border border-(--platform-button-primary-border) bg-(image:--platform-button-primary-fill) px-3.5 py-[9px] text-(--platform-button-primary-text)"
type="button"
onClick={() => void submit()}
disabled={
busy ||
!eventsReady ||
!selectedFingerprints.size ||
!logsReady ||
(includeLogs && logsError)
@@ -12,7 +12,10 @@ export function ErrorReportNotice() {
const [notificationEvents, setNotificationEvents] = useState<
ClientErrorEvent[]
>([]);
const [reportEvents, setReportEvents] = useState<ClientErrorEvent[]>([]);
const [reportFallbackEvents, setReportFallbackEvents] = useState<
ClientErrorEvent[]
>([]);
const [reportOpen, setReportOpen] = useState(false);
const requestId = useRef(0);
const retryTimer = useRef<number | null>(null);
@@ -65,31 +68,37 @@ export function ErrorReportNotice() {
}, [loadEvents]);
const openReport = () => {
setReportEvents(notificationEvents);
setReportFallbackEvents(notificationEvents);
setReportOpen(true);
setNotificationEvents([]);
};
const closeReport = () => {
setReportOpen(false);
setReportFallbackEvents([]);
};
return (
<>
{notificationEvents.length ? (
<aside
className="fixed top-[calc(var(--window-chrome-height)+14px)] right-4 z-40 flex max-w-[min(420px,calc(100vw-2rem))] items-center gap-2.5 rounded-xl border border-[var(--platform-surface-border)] bg-[var(--platform-subpanel-fill)] px-3.5 py-3 text-[var(--platform-text-strong)] shadow-[0_12px_30px_rgb(36_20_12_/_18%)] max-[560px]:top-[calc(var(--window-chrome-height)+8px)] max-[560px]:right-2 max-[560px]:left-2 max-[560px]:max-w-none"
className="fixed top-[calc(var(--window-chrome-height)+12px)] right-4 z-40 flex max-w-[min(520px,calc(100vw-2rem))] items-center gap-4 rounded-xl border border-[#efc9ae] bg-[#fffaf5] px-3.5 py-3 shadow-[0_8px_24px_rgb(100_49_26_/_16%)]"
role="status"
aria-live="polite"
>
<span className="flex-[1_1_auto]">
{notificationEvents.length}
</span>
<div className="grid min-w-0 gap-[3px]">
<strong className="text-[13px] text-[#4a220f]"></strong>
</div>
<button
className="min-h-9 cursor-pointer rounded-lg border-0 bg-[var(--platform-button-primary-fill)] px-2.5 text-[var(--platform-button-primary-text)] last:w-9 last:bg-transparent last:px-0 last:text-xl last:text-[var(--platform-text-soft)]"
className="flex-[0_0_auto] cursor-pointer rounded-lg border-0 bg-[#c7653d] px-3 py-[7px] text-[12px] font-bold text-white disabled:cursor-wait disabled:opacity-[0.65]"
type="button"
onClick={openReport}
>
</button>
<button
className="min-h-9 cursor-pointer rounded-lg border-0 bg-[var(--platform-button-primary-fill)] px-2.5 text-[var(--platform-button-primary-text)] last:w-9 last:bg-transparent last:px-0 last:text-xl last:text-[var(--platform-text-soft)]"
type="button"
className="inline-flex h-7 w-7 flex-[0_0_auto] cursor-pointer items-center justify-center rounded-md border-0 bg-transparent p-0 text-[#8d6a58] hover:bg-[rgb(199_101_61_/_12%)] hover:text-[#4a220f] disabled:cursor-wait disabled:opacity-50"
onClick={() => {
const dismissed = notificationEvents;
setNotificationEvents([]);
@@ -102,9 +111,9 @@ export function ErrorReportNotice() {
</aside>
) : null}
<ErrorReportDialog
open={reportEvents.length > 0}
events={reportEvents}
onClose={() => setReportEvents([])}
open={reportOpen}
fallbackEvents={reportFallbackEvents}
onClose={closeReport}
/>
</>
);
@@ -0,0 +1,81 @@
/** @vitest-environment jsdom */
import { cleanup, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
const mockState = vi.hoisted(() => ({
getPending: vi.fn(),
readLogs: vi.fn(),
}));
vi.mock('../src/services/errorReporting', () => ({
ackClientErrorEventsWithRetry: vi.fn(),
getPendingClientErrorEvents: mockState.getPending,
getStableErrorReportSubmissionId: vi.fn(),
readApplicationDiagnosticLogs: mockState.readLogs,
submitErrorReportBatch: vi.fn(),
}));
vi.mock('../src/components/modal/ThemedModal', () => ({
ThemedModal: ({ open, children }: { open: boolean; children: ReactNode }) =>
open ? <div role="dialog">{children}</div> : null,
}));
import { ErrorReportDialog } from '../src/components/error-report/ErrorReportDialog';
const event = {
eventId: 'rust-error-1',
fingerprint: 'fingerprint-1',
source: 'test',
message: '打开面板时读取的错误',
occurredAt: '1',
count: 1,
};
describe('ErrorReportDialog', () => {
afterEach(() => {
cleanup();
mockState.getPending.mockReset();
mockState.readLogs.mockReset();
});
it('只在面板打开时读取最新错误快照', async () => {
mockState.getPending.mockResolvedValue([event]);
mockState.readLogs.mockResolvedValue([]);
const view = render(<ErrorReportDialog open={false} onClose={vi.fn()} />);
expect(mockState.getPending).not.toHaveBeenCalled();
view.rerender(<ErrorReportDialog open onClose={vi.fn()} />);
expect(await screen.findByText(event.message)).toBeTruthy();
expect(mockState.getPending).toHaveBeenCalledTimes(1);
});
it('读取错误事件失败时显示不可用状态而不是空列表', async () => {
mockState.getPending.mockRejectedValue(new Error('读取失败'));
mockState.readLogs.mockResolvedValue([]);
render(<ErrorReportDialog open onClose={vi.fn()} />);
expect(
await screen.findByText('错误事件暂不可用,请关闭后重试。'),
).toBeTruthy();
expect(screen.queryByText('当前没有待报告的错误。')).toBeNull();
});
it('最新快照读取失败时使用打开通知时的事件快照', async () => {
mockState.getPending.mockRejectedValue(new Error('瞬时读取失败'));
mockState.readLogs.mockResolvedValue([]);
render(
<ErrorReportDialog open fallbackEvents={[event]} onClose={vi.fn()} />,
);
expect(await screen.findByText(event.message)).toBeTruthy();
expect(
screen.getByText('最新错误读取失败,已使用打开通知时的快照'),
).toBeTruthy();
});
});
@@ -1,6 +1,12 @@
/** @vitest-environment jsdom */
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
const mockState = vi.hoisted(() => ({
@@ -8,6 +14,10 @@ const mockState = vi.hoisted(() => ({
listeners: [] as Array<() => void>,
}));
const dialogState = vi.hoisted(() => ({
fallbackEvents: [] as (typeof event)[],
}));
vi.mock('../src/services/errorReporting', () => ({
getPendingClientErrorEvents: mockState.getPending,
subscribeClientErrorEvents: (listener: () => void) => {
@@ -21,7 +31,16 @@ vi.mock('../src/services/errorReporting', () => ({
}));
vi.mock('../src/components/error-report/ErrorReportDialog', () => ({
ErrorReportDialog: () => null,
ErrorReportDialog: ({
fallbackEvents,
open,
}: {
fallbackEvents?: (typeof event)[];
open: boolean;
}) => {
dialogState.fallbackEvents = fallbackEvents ?? [];
return open ? <div data-testid="error-report-dialog" /> : null;
},
}));
import { ErrorReportNotice } from '../src/components/error-report/ErrorReportNotice';
@@ -47,7 +66,7 @@ describe('ErrorReportNotice', () => {
render(<ErrorReportNotice />);
expect(await screen.findByText('发现 1 个问题')).toBeTruthy();
expect(await screen.findByText('发现问题')).toBeTruthy();
expect(mockState.getPending).toHaveBeenCalledTimes(1);
});
@@ -61,7 +80,19 @@ describe('ErrorReportNotice', () => {
mockState.listeners[0]?.();
expect(await screen.findByText('发现 1 个问题')).toBeTruthy();
expect(await screen.findByText('发现问题')).toBeTruthy();
expect(mockState.getPending).toHaveBeenCalledTimes(2);
});
it('打开报告时保留通知快照作为对话框 fallback', async () => {
mockState.getPending.mockResolvedValue([event]);
render(<ErrorReportNotice />);
await screen.findByText('发现问题');
fireEvent.click(screen.getByRole('button', { name: '查看并报告' }));
expect(await screen.findByTestId('error-report-dialog')).toBeTruthy();
expect(dialogState.fallbackEvents).toEqual([event]);
});
});
@@ -11,9 +11,11 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事
- 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。
- 客户端 API 自动采集只覆盖网络错误、408 和 5xx;预期的 4xx 登录/鉴权失败不进入错误报告池。
- Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log,并在客户端桥接处再次脱敏;`read_diagnostic_logs` 只读取应用级日志。
- 报告面板只由自动诊断通知中的“查看并报告”打开,不提供聊天命令、崩溃页按钮或其他手动入口;默认选中最新事件,其他事件可勾选。允许填写最多 2,000 字中文描述并取消日志附件;本版本不支持截图或任意文件附件。
- 报告面板只由自动诊断通知中的“查看并报告”打开,不提供聊天命令、崩溃页按钮或其他手动入口;默认选中当前快照中的全部事件,用户可取消不想提交的事件。允许填写最多 2,000 字中文描述并取消日志附件;本版本不支持截图或任意文件附件。
- 报告面板读取当前错误快照失败时,必须明确显示“错误事件暂不可用,请关闭后重试”,不能把失败误显示为“当前没有待报告的错误”。
- 通知中的“查看并报告”打开面板时必须保留该次通知快照;最新快照读取瞬时失败时使用这份 fallback 继续展示和提交,不能因先清空通知而丢失用户刚看到的事件。
- Rust 是结构化错误队列的唯一真相源:`src-tauri/src/error_report/` 负责脱敏、调用点指纹、eventId、计数、100 条上限、5 秒聚合和 ack 生命周期;WebView 仅通过 Tauri bridge 上报、读取快照并保存短暂 React 展示状态,不维护第二份事件 Map。Runtime 错误上报统一使用 `source=agent-runtime``action=agent-runtime` 和 Agent ID 作为 page;包含 `kind=codex-app-server-*``codex-app-server-error:*` 的消息在入池前归一为稳定类别(例如 `codex-app-server-error:other`),不把 public summary 中的动态 fingerprint/长度作为分桶输入。Rust emit 只作为无状态唤醒,携带单调递增的 generation,不携带错误正文或事件 ID。
- 错误事件先在当前进程内存池按 fingerprint 合并,经过 5 秒聚合后只发出一次非阻塞存在性唤醒;WebView 挂载、收到唤醒、重新获得焦点或恢复可见时都查询完整未 ack 快照,并在桥接暂时失败时做有限退避重试。通知支持“查看并报告”和“忽略”,同一 fingerprint 仅在新增时唤醒一次。通知不直接打开阻塞式报告面板。忽略只关闭当前 UI,不删除事件;提交成功后由 bridge ack/delete 选中事件。
- 错误事件先在当前进程内存池按 fingerprint 合并,经过 5 秒聚合后只发出一次非阻塞存在性唤醒;WebView 挂载、收到唤醒、重新获得焦点或恢复可见时都查询完整未 ack 快照,并在桥接暂时失败时做有限退避重试。通知支持“查看并报告”和“忽略”,同一 fingerprint 仅在新增时唤醒一次。通知不直接打开阻塞式报告面板;用户打开报告面板后,面板再读取一次完整未 ack 快照,确保提交使用打开时的最新事件。忽略只关闭当前 UI,不删除事件;提交成功后由 bridge ack/delete 选中事件。
- 上传失败只在当前进程显示失败并允许用户再次提交,不跨重启恢复事件池,不后台自动重试。
## HTTP 与存储