From f3bb718afce199cdd716ade9a55edd3f07f7bc4b Mon Sep 17 00:00:00 2001 From: vastsa Date: Sun, 27 Sep 2026 02:57:53 +0800 Subject: [PATCH] fix(renderer): reduce stream and destination stalls Bound smooth-text React commits on high-refresh displays and stop the animation loop when it catches up. Keep destination loading states explicit and prevent older workspace responses from replacing current pull requests. Cover the cadence, Settings focus, pending refresh, and workspace-switch paths with isolated UI tests. --- apps/desktop/src/features/app/AppShell.tsx | 1 - .../src/features/settings/SettingsPage.tsx | 6 + apps/desktop/src/hooks/useSmoothText.ts | 27 ++- apps/desktop/src/pages/PullRequestsPage.tsx | 53 ++++-- .../app-shell-settings-accessibility.test.mjs | 32 ++++ .../test/pull-requests-loading-state.test.mjs | 28 ++++ .../test/sidebar-settings-return.test.mjs | 4 +- .../test/smooth-text-throttle.test.mjs | 14 ++ docs/spec/04-ux/01-ui-ia.md | 5 +- docs/spec/06-delivery/04-e2e-test-plan.md | 40 ++++- docs/zh-CN/spec/04-ux/01-ui-ia.md | 4 +- .../spec/06-delivery/04-e2e-test-plan.md | 25 ++- package.json | 1 + scripts/README.md | 3 + scripts/e2e-destination-loading.mjs | 97 +++++++++++ scripts/e2e-transcript-render.mjs | 7 +- scripts/e2e/pull-requests-loading.jsx | 154 ++++++++++++++++++ scripts/e2e/settings-scroll.jsx | 4 + scripts/e2e/transcript-render.tsx | 89 ++++++++++ 19 files changed, 566 insertions(+), 28 deletions(-) create mode 100644 apps/desktop/test/app-shell-settings-accessibility.test.mjs create mode 100644 apps/desktop/test/pull-requests-loading-state.test.mjs create mode 100644 apps/desktop/test/smooth-text-throttle.test.mjs create mode 100644 scripts/e2e-destination-loading.mjs create mode 100644 scripts/e2e/pull-requests-loading.jsx diff --git a/apps/desktop/src/features/app/AppShell.tsx b/apps/desktop/src/features/app/AppShell.tsx index 20ec725df1..bb1f32c662 100644 --- a/apps/desktop/src/features/app/AppShell.tsx +++ b/apps/desktop/src/features/app/AppShell.tsx @@ -111,7 +111,6 @@ export function AppShell() { className="app-chat-shell" hidden={page === "settings"} inert={page === "settings" ? true : undefined} - aria-hidden={page === "settings" ? true : undefined} > {!sidebarCollapsed || sidebarExiting ? ( (null); + const settingsSearchRef = useRef(null); const destination = activeExtension ? `extension:${activeExtension.ref}` : `builtin:${tab}`; + useLayoutEffect(() => { + settingsSearchRef.current?.focus({ preventScroll: true }); + }, []); + useLayoutEffect(() => { // Reset before paint and before the search-anchor effect positions its row. if (contentRef.current) contentRef.current.scrollTop = 0; @@ -283,6 +288,7 @@ export function SettingsPage() {
setQuery(e.target.value)} diff --git a/apps/desktop/src/hooks/useSmoothText.ts b/apps/desktop/src/hooks/useSmoothText.ts index be0a1ff9d7..bb0444fd6f 100644 --- a/apps/desktop/src/hooks/useSmoothText.ts +++ b/apps/desktop/src/hooks/useSmoothText.ts @@ -19,11 +19,14 @@ export function useSmoothText( const revealedRef = useRef(source.length); const rafRef = useRef(null); const lastFrameRef = useRef(0); + const fractionalAdvanceRef = useRef(0); // When not enabled or not streaming, always show full text useEffect(() => { if (!enabled || !streaming) { revealedRef.current = source.length; + lastFrameRef.current = 0; + fractionalAdvanceRef.current = 0; setRevealed(source.length); if (rafRef.current !== null) { cancelAnimationFrame(rafRef.current); @@ -39,12 +42,19 @@ export function useSmoothText( const tick = (now: number) => { const backlog = source.length - revealedRef.current; if (backlog <= 0) { - // Nothing to release; wait for more content - rafRef.current = requestAnimationFrame(tick); + // The source dependency restarts this effect when more text arrives. + rafRef.current = null; + lastFrameRef.current = 0; + fractionalAdvanceRef.current = 0; return; } const elapsed = now - lastFrameRef.current; + // Keep React and Markdown commits at or below 60 Hz on high-refresh screens. + if (elapsed < 1000 / 60) { + rafRef.current = requestAnimationFrame(tick); + return; + } lastFrameRef.current = now; // Base speed: ~60 chars/sec. Adapt: if backlog > ~30 chars (~500ms), @@ -57,7 +67,14 @@ export function useSmoothText( : baseCharsPerSec; const dt = Math.min(elapsed, 100) / 1000; // cap dt to avoid big jumps - const advance = Math.max(1, Math.round(speed * dt)); + // Carry fractional characters so skipped frames do not slow the reveal. + const exactAdvance = fractionalAdvanceRef.current + speed * dt; + const advance = Math.floor(exactAdvance); + fractionalAdvanceRef.current = exactAdvance - advance; + if (advance === 0) { + rafRef.current = requestAnimationFrame(tick); + return; + } const next = Math.min(revealedRef.current + advance, source.length); revealedRef.current = next; @@ -66,7 +83,9 @@ export function useSmoothText( rafRef.current = requestAnimationFrame(tick); }; - lastFrameRef.current = performance.now(); + if (lastFrameRef.current === 0) { + lastFrameRef.current = performance.now(); + } rafRef.current = requestAnimationFrame(tick); return () => { diff --git a/apps/desktop/src/pages/PullRequestsPage.tsx b/apps/desktop/src/pages/PullRequestsPage.tsx index 88c57d0d4b..dfcb4f4f3e 100644 --- a/apps/desktop/src/pages/PullRequestsPage.tsx +++ b/apps/desktop/src/pages/PullRequestsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import type { PullRequestSummary } from "@pi-desktop/shared"; import { useAppStore } from "../stores/app-store"; @@ -11,6 +11,7 @@ type Filter = "open" | "draft" | "all"; export function PullRequestsPage() { const { t } = useTranslation(); const workspace = useAppStore((s) => s.workspace); + const workspacePath = workspace?.path ?? null; const openProject = useAppStore((s) => s.openProject); const newSession = useAppStore((s) => s.newSession); const setPage = useAppStore((s) => s.setPage); @@ -18,40 +19,55 @@ export function PullRequestsPage() { const [pulls, setPulls] = useState([]); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const [loadedWorkspacePath, setLoadedWorkspacePath] = useState(null); const [filter, setFilter] = useState("open"); + const requestSequence = useRef(0); - const refresh = async () => { + const refresh = useCallback(async () => { + const request = ++requestSequence.current; setLoading(true); try { const res = await api.listPullRequests(); + if (request !== requestSequence.current) return; setPulls(res.pulls || []); setError(res.error || null); + setLoadedWorkspacePath(workspacePath); } catch (e) { + if (request !== requestSequence.current) return; setPulls([]); setError(e instanceof Error ? e.message : String(e)); + setLoadedWorkspacePath(workspacePath); } finally { - setLoading(false); + if (request === requestSequence.current) setLoading(false); } - }; + }, [workspacePath]); useEffect(() => { void refresh(); - }, [workspace?.path]); + return () => { + requestSequence.current += 1; + }; + }, [refresh]); + + const workspaceDataCurrent = loadedWorkspacePath === workspacePath; + const visiblePulls = workspaceDataCurrent ? pulls : []; + const visibleError = workspaceDataCurrent ? error : null; + const pageLoading = Boolean(workspacePath) && (loading || !workspaceDataCurrent); const filtered = useMemo(() => { - if (filter === "all") return pulls; - if (filter === "draft") return pulls.filter((p) => p.isDraft); - return pulls.filter((p) => !p.isDraft); - }, [pulls, filter]); + if (filter === "all") return visiblePulls; + if (filter === "draft") return visiblePulls.filter((p) => p.isDraft); + return visiblePulls.filter((p) => !p.isDraft); + }, [visiblePulls, filter]); const counts = useMemo(() => { - const draft = pulls.filter((p) => p.isDraft).length; + const draft = visiblePulls.filter((p) => p.isDraft).length; return { - open: pulls.length - draft, + open: visiblePulls.length - draft, draft, - all: pulls.length, + all: visiblePulls.length, }; - }, [pulls]); + }, [visiblePulls]); return (
@@ -117,14 +133,21 @@ export function PullRequestsPage() { {t("project.open")} + ) : pageLoading && visiblePulls.length === 0 ? ( +
+ + + {t("app.loadingView")} + +
) : filtered.length === 0 ? (
{t("pulls.emptyTitle")}
- {error && error !== "NO_WORKSPACE" ? ( -
{error}
+ {visibleError && visibleError !== "NO_WORKSPACE" ? ( +
{visibleError}
) : null}
) : ( diff --git a/apps/desktop/test/app-shell-settings-accessibility.test.mjs b/apps/desktop/test/app-shell-settings-accessibility.test.mjs new file mode 100644 index 0000000000..467aae0a3c --- /dev/null +++ b/apps/desktop/test/app-shell-settings-accessibility.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const appShell = await readFile( + new URL("../src/features/app/AppShell.tsx", import.meta.url), + "utf8", +); +const settingsPage = await readFile( + new URL("../src/features/settings/SettingsPage.tsx", import.meta.url), + "utf8", +); + +test("settings hides and inerts chat without aria-hiding a focused descendant", () => { + const chatShell = appShell.match( + //, + )?.[0]; + + assert.ok(chatShell, "chat shell must exist"); + assert.match(chatShell, /hidden=\{page === "settings"\}/); + assert.match(chatShell, /inert=\{page === "settings" \? true : undefined\}/); + assert.doesNotMatch(chatShell, /aria-hidden/); +}); + +test("entering Settings moves focus to its first search control", () => { + assert.match(settingsPage, /const settingsSearchRef = useRef\(null\)/); + assert.match( + settingsPage, + /settingsSearchRef\.current\?\.focus\(\{ preventScroll: true \}\)/, + ); + assert.match(settingsPage, / { + const noWorkspaceBranch = page.indexOf("!workspace?.path ? ("); + const loadingBranch = page.indexOf("pageLoading && visiblePulls.length === 0 ? ("); + const emptyBranch = page.indexOf("filtered.length === 0 ? ("); + + assert.ok(noWorkspaceBranch >= 0); + assert.ok(loadingBranch > noWorkspaceBranch); + assert.ok(emptyBranch > loadingBranch); + assert.match(page.slice(loadingBranch, emptyBranch), /role="status"/); + assert.match(page.slice(loadingBranch, emptyBranch), /aria-busy="true"/); + assert.match(page.slice(loadingBranch, emptyBranch), /t\("app\.loadingView"\)/); +}); + +test("pull request refresh ignores results from an old request or workspace", () => { + assert.match(page, /const request = \+\+requestSequence\.current/); + assert.match(page, /if \(request !== requestSequence\.current\) return;/); + assert.match(page, /const workspaceDataCurrent = loadedWorkspacePath === workspacePath/); + assert.match(page, /return \(\) => \{\s*requestSequence\.current \+= 1;/); +}); diff --git a/apps/desktop/test/sidebar-settings-return.test.mjs b/apps/desktop/test/sidebar-settings-return.test.mjs index d14e26a1c8..5144625628 100644 --- a/apps/desktop/test/sidebar-settings-return.test.mjs +++ b/apps/desktop/test/sidebar-settings-return.test.mjs @@ -15,7 +15,9 @@ test("Settings hides the mounted chat shell and keeps its portal layers out of v assert.match(appShell, //); assert.match(appShell, /className="app-chat-shell"[\s\S]*?hidden=\{page === "settings"\}/); assert.match(appShell, /inert=\{page === "settings" \? true : undefined\}/); - assert.match(appShell, /aria-hidden=\{page === "settings" \? true : undefined\}/); + const chatShell = appShell.match(//)?.[0]; + assert.ok(chatShell, "chat shell must exist"); + assert.doesNotMatch(chatShell, /aria-hidden/); assert.match(appShell, /className="app-chat-shell"[\s\S]*?/); assert.match(appShell, /\{page === "settings" \? \([\s\S]*?/); assert.match(appShell, //); diff --git a/apps/desktop/test/smooth-text-throttle.test.mjs b/apps/desktop/test/smooth-text-throttle.test.mjs new file mode 100644 index 0000000000..34fab6ab17 --- /dev/null +++ b/apps/desktop/test/smooth-text-throttle.test.mjs @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const hook = await readFile( + new URL("../src/hooks/useSmoothText.ts", import.meta.url), + "utf8", +); + +test("smooth text caps renderer commits at 60 Hz and stops when caught up", () => { + assert.match(hook, /if \(elapsed < 1000 \/ 60\)/); + assert.match(hook, /if \(backlog <= 0\) \{[\s\S]*?rafRef\.current = null;/); + assert.match(hook, /const exactAdvance = fractionalAdvanceRef\.current \+ speed \* dt/); +}); diff --git a/docs/spec/04-ux/01-ui-ia.md b/docs/spec/04-ux/01-ui-ia.md index 8ac25b7b0b..a6266afac4 100644 --- a/docs/spec/04-ux/01-ui-ia.md +++ b/docs/spec/04-ux/01-ui-ia.md @@ -245,7 +245,10 @@ destination, chat as the home surface, tools and permissions inline. ### 3.3 Pull requests Segmented Open/Draft/All filters with counts; rows carry icon plate, number, title, status badge, branch meta, external link, and "Review with agent" -(creates a chat turn). Requires an active workspace and `gh`. +(creates a chat turn). Requires an active workspace and `gh`. While the current +workspace's list is loading, show a localized status instead of the empty-result +state. Keep current rows visible during an explicit refresh; when the workspace +changes, hide rows from the previous workspace and ignore stale request results. ### 3.4 Scheduled Tasks and Run history views, with an explicit create/edit form, a cadence dropdown, time, diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 69718f75aa..78b0f169fd 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -1775,6 +1775,35 @@ identify the platform validation still needed. (`apps/desktop/test/plugins-page-style.test.mjs`, `apps/desktop/test/route-scroll.test.mjs`); full UI scenario Draft +#### E2E-087b: Destination loading and Settings focus remain explicit + +- **Preconditions**: Isolated Electron profile, two local workspace fixtures, + and a controlled preload test double that serves fixture pull requests and + can hold each request until released. No live GitHub access is used. +- **Steps**: + 1. Mount the production Settings page and verify focus moves to its search + control. + 2. Mount Pull requests for the first workspace and hold its list result. + Inspect the page while it is pending. + 3. Switch to the second workspace and release its result. Start an explicit + refresh, hold that response, and verify its current rows remain visible. + 4. Release the refresh response, then complete the first workspace request + last. +- **Expected**: Settings search owns focus on mount. Pull requests shows a + localized loading status rather than the empty-result state while the first + request is pending. Explicit refresh keeps current rows visible while its + response is pending. After switching workspaces, the second workspace's rows + and filter counts remain visible when the older request completes; no row + from the first workspace replaces them. The shell contract keeps the hidden + chat inert without applying `aria-hidden` to a focused descendant. +- **Specs linked**: `04-ux/01-ui-ia.md` (§3.3), + `04-ux/09-interaction-patterns.md` (§7.1) +- **Acceptance**: C (UI), Quality +- **Milestone**: M6+ +- **Status**: Production component E2E and source-contract checks automated; + covered by `pnpm test:e2e:settings-scroll` and + `pnpm test:e2e:destination-loading`; full shell navigation scenario Draft + #### E2E-088: Composer Agent/Plan/Goal chip updates the session - **Preconditions**: Chat route active; a session selected. @@ -5496,6 +5525,8 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. - **Expected**: - The current assistant row reveals content progressively and pinned follow stays at latest without visible oscillation. + - Smooth text reveal on a simulated 120 Hz display commits no faster than + 60 Hz, and its animation frame loop stops after it catches up. - Replaceable message/tool partials are coalesced to the next paint, while terminal, permission, planning, and error states remain immediate. - A failed tool row remains error-hued and locally expandable, but never marks @@ -5534,9 +5565,10 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. credentials; requires installed Electron and a graphical session, or Xvfb on Linux). It mounts production transcript components, counts ActivityGroup renders across 20 text updates with 100 completed groups, checks changed tool - content, and checks cross-part Task terminal status/timing updates. The page - links the app's built stylesheet, which the runtime-status scenario below - measures real geometry against; full provider streaming and shell + content, checks cross-part Task terminal status/timing updates, and exercises + the production smooth-text hook against deterministic 120 Hz animation frames. + The page links the app's built stylesheet, which the runtime-status scenario + below measures real geometry against; full provider streaming and shell responsiveness remain Draft. #### E2E-CHAT-running-status-survives-output-pauses @@ -8568,6 +8600,7 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. | C / G / Quality — Plugins navigation | E2E-NAV-plugins-button-goes-back | | C / D / Quality — Sidebar row states | E2E-LAYOUT-sidebar-row-states | | A / C / Quality — Sidebar material and settings return | E2E-LAYOUT-sidebar-settings | +| C / Quality — Destination loading and focus | E2E-087b | | A / H / Quality — Renderer process crash recovery | E2E-RUNTIME-renderer-crash-recovery | | B / F / Security — Provider copy | E2E-PROVIDER-copy-config-without-credentials | | B / F / Quality — Selected model order | E2E-MODEL-selected-order-persists | @@ -8639,6 +8672,7 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. | M6+ | E2E-121, E2E-122, E2E-148, E2E-150, E2E-151, E2E-154, E2E-155, E2E-158, E2E-159, E2E-160, E2E-161, E2E-162, E2E-163, E2E-166, E2E-168, E2E-173, E2E-174, E2E-176, E2E-179, E2E-196a, E2E-196b, E2E-196c, E2E-198, E2E-199, E2E-200, E2E-202, E2E-203, E2E-205, E2E-209, E2E-210, E2E-UPDATE-preference-and-once-only-reminder, E2E-212, E2E-213, E2E-214, E2E-215, E2E-216, E2E-217, E2E-218, E2E-259, E2E-219, E2E-257, E2E-SUBAGENT-settlement-updates-before-parent-poll, E2E-PLUGIN-fs-root-follows-the-calling-session, E2E-SUBAGENT-resume-a-settled-delegation | | M6+ (Session Orchestrator) | E2E-PLUGIN-session-orchestrator-real-workers | | M6+ (Selected model order) | E2E-MODEL-selected-order-persists | +| M6+ (Destination loading and focus) | E2E-087b | | M6+ (Session list responsiveness) | E2E-SESSION-list-refresh-keeps-desktop-responsive | | M6+ (Windows updater cache) | E2E-260 | | M6+ (Independent session communication) | E2E-SESSION-independent-top-level-communication, E2E-SESSION-hover-card-model-and-links | diff --git a/docs/zh-CN/spec/04-ux/01-ui-ia.md b/docs/zh-CN/spec/04-ux/01-ui-ia.md index 3e4e1a2a12..326ed93d8c 100644 --- a/docs/zh-CN/spec/04-ux/01-ui-ia.md +++ b/docs/zh-CN/spec/04-ux/01-ui-ia.md @@ -206,7 +206,9 @@ ### 3. 3 拉取请求 带计数的分段 Open/Draft/All 过滤器;行带有图标板、数字、 标题、状态徽章、分支元、外部链接和“与代理一起审核” -(创建聊天回合)。需要活动工作区和 `gh`。 +(创建聊天回合)。需要活动工作区和 `gh`。当前工作区的列表加载时,显示本地化 +状态而不是空结果;手动刷新时保留现有行。切换工作区后隐藏旧工作区的行,并忽略 +过期请求结果。 ### 3. 4 预定 任务与运行记录两个视图,支持创建、编辑、暂停、启用和确认删除。表单可为每个任务单独选择项目、权限和模型。周期保留下拉选择,并与时分统一为自定义主题菜单。每小时按一小时间隔执行,不显示时间选择;保存、启用、启动或上次自动准入后重新计时。 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 561f282e4a..5eb4f4533e 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -742,6 +742,25 @@ task-candidate E2E 从请求工作树运行,但使用主工作区已经准备 - **状态**:已覆盖源级回归测试 (`apps/desktop/test/plugins-page-style.test.mjs`);完整 UI 场景为草案 +#### E2E-087b:目的页加载与设置焦点保持明确 + +- **先决条件**:隔离的 Electron 配置、两个本地工作区 fixture,以及可按需 + 暂停列表响应的 preload 测试替身;不访问在线 GitHub。 +- **步骤**: + 1. 挂载生产设置页面,确认焦点移到搜索框。 + 2. 为第一个工作区挂载 Pull requests 并暂停列表响应,检查等待状态。 + 3. 切换到第二个工作区并返回列表结果。发起手动刷新、暂停响应,确认现有行仍显示。 + 4. 返回刷新响应,最后才完成第一个工作区的旧请求。 +- **预期**:设置搜索框在挂载时获得焦点。Pull requests 在首个列表加载期间显示本地化 + 状态而非空结果。手动刷新响应期间保留已有行。切换工作区后,旧请求完成不得替换 + 第二个工作区的行或过滤计数。Shell 仍会将隐藏聊天设为 inert,但不会对包含焦点的 + 后代应用 `aria-hidden`。 +- **链接规格**:`04-ux/01-ui-ia.md`(§3.3)、`04-ux/09-interaction-patterns.md`(§7.1) +- **验收**:C(UI)、质量 +- **里程碑**:M6+ +- **状态**:生产组件 E2E 与源码契约检查已自动化,由 + `pnpm test:e2e:settings-scroll` 和 `pnpm test:e2e:destination-loading` 覆盖;完整 Shell 导航场景仍为草稿 + #### E2E-088:Composer Agent/Plan/Goal 芯片更新会话 - **先决条件**:聊天路线激活;选定的会话。 @@ -3448,6 +3467,7 @@ IPC 请求无法关闭。 - **预期**: - 当前助手行逐步显示内容并固定关注 保持在最晚,没有明显的振荡。 + - 在模拟的 120Hz 显示器上,平滑文本最多以 60Hz 更新;追上后动画帧循环停止。 - 可替换的 message/tool 部分会合并到下一个油漆,同时 终端、许可、计划和错误状态仍然是即时的。 - 失败的工具行仍然带有错误色调并且可以局部扩展,但永远不会标记 @@ -3484,7 +3504,8 @@ IPC 请求无法关闭。 `pnpm test:e2e:transcript` 自动验证 React/Chromium 渲染回归(无需提供商 凭据;需安装 Electron,并有图形会话,Linux 可用 Xvfb)。该测试挂载生产 聊天组件,统计 100 个已完成活动组在 20 次文本更新中的 ActivityGroup - 渲染次数,并检查工具内容变化和跨活动段的 Task 终态及耗时更新。 + 渲染次数,并检查工具内容变化、跨活动段的 Task 终态及耗时更新,以及使用 + 确定性 120Hz 动画帧验证生产平滑文本 hook。 测试不加载样式;完整提供商流式响应与 shell 交互响应性仍为草稿。 #### E2E-084:长工具循环在提供程序上下文限制之前压缩 @@ -5378,6 +5399,7 @@ eleven-tool-round desktop paths are verified by | C / G / Quality — Plugins navigation | E2E-NAV-plugins-button-goes-back | | C / D / Quality — 侧边栏行状态 | E2E-LAYOUT-sidebar-row-states | | A / C / Quality — 侧栏材质与设置返回 | E2E-LAYOUT-sidebar-settings | +| C / Quality — 目的页加载与焦点 | E2E-087b | | A / H / Quality — 渲染器进程崩溃恢复 | E2E-RUNTIME-renderer-crash-recovery | | B / F / Security — 提供商复制 | E2E-PROVIDER-copy-config-without-credentials | | B / F / Quality — 已选模型顺序 | E2E-MODEL-selected-order-persists | @@ -5449,6 +5471,7 @@ eleven-tool-round desktop paths are verified by | M6+ | E2E-121、E2E-122、E2E-123、E2E-142、E2E-148、E2E-150、E2E-151、E2E-168、E2E-199、E2E-200、E2E-202、E2E-203、E2E-209、E2E-211、E2E-UPDATE-preference-and-once-only-reminder、E2E-212、E2E-213、E2E-214、E2E-215、E2E-216、E2E-217、E2E-257、E2E-166、E2E-SUBAGENT-resume-a-settled-delegation | | M6+(Session Orchestrator) | E2E-PLUGIN-session-orchestrator-real-workers | | M6+(已选模型顺序) | E2E-MODEL-selected-order-persists | +| M6+(目的页加载与焦点) | E2E-087b | | M6+(会话列表响应性) | E2E-SESSION-list-refresh-keeps-desktop-responsive | | M6+(Windows 更新缓存) | E2E-260 | | M6+(独立会话通信) | E2E-SESSION-independent-top-level-communication、E2E-SESSION-hover-card-model-and-links | diff --git a/package.json b/package.json index b16052d715..dfabf9fc8b 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "test:e2e:hosted-search": "node scripts/e2e-hosted-search.mjs", "test:e2e:oauth-retry": "node --test apps/desktop/test/anthropic-oauth-retry.test.mjs", "test:e2e:boot": "node scripts/e2e-electron-boot.mjs", + "test:e2e:destination-loading": "node scripts/e2e-destination-loading.mjs", "test:e2e:settings-scroll": "node scripts/e2e-settings-scroll.mjs", "test:e2e:config-sync": "node scripts/e2e-config-sync-multidevice.mjs", "test:e2e:layout": "node scripts/e2e-three-column-layout.mjs", diff --git a/scripts/README.md b/scripts/README.md index 33aaa6005c..9997806553 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -53,7 +53,10 @@ they cover are specified in | `e2e-plan.mjs` | `pnpm test:e2e:plan` | Plan state, checkpoint artifact, and approval transitions | | `e2e-plan-ui.mjs` | `pnpm test:e2e:plan-ui` | Plan approval through the rendered UI | | `e2e-electron-boot.mjs` | `pnpm test:e2e:boot` | Electron boot probe | +| `e2e-destination-loading.mjs` | `pnpm test:e2e:destination-loading` | Production Pull requests component with held preload responses; checks pending, refresh, and stale-workspace loading states without live GitHub access | | `e2e-provider-recovery.mjs` | `node scripts/e2e-provider-recovery.mjs` | Isolated desktop with a localhost fault-injection provider: socket failures, interrupted streams, Responses recovery, exhausted retries, Continue, and recovery across eleven real Read calls. Requires a built desktop/runtime and host binary (`PI_DESKTOP_HOST_BIN` when outside the checkout); retains screenshots and JSON under `.artifacts/issue-699/` | +| `e2e-settings-scroll.mjs` | `pnpm test:e2e:settings-scroll` | Production Settings component navigation, search anchoring, focus-on-mount, and scroll behavior | +| `e2e-transcript-render.mjs` | `pnpm test:e2e:transcript` | Production transcript render boundaries, runtime status geometry, and deterministic smooth-text cadence | | `e2e-config-sync-multidevice.mjs` | `pnpm test:e2e:config-sync` | Two isolated host-core devices against an ephemeral local WebDAV fixture; covers encrypted sync, new-device approval, cross-device edits, conditional requests, and ciphertext-only remote storage | | `e2e-supervision.mjs` | `pnpm test:e2e:supervision` | Process supervision and restart behavior | | `e2e-subagents.mjs` | `pnpm test:e2e:subagents` | Subagent registry over RPC, then through the real loader (D202) | diff --git a/scripts/e2e-destination-loading.mjs b/scripts/e2e-destination-loading.mjs new file mode 100644 index 0000000000..8d8b3796ba --- /dev/null +++ b/scripts/e2e-destination-loading.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** Production Settings and Pull requests component lifecycle in isolated Electron. */ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { repositoryRoot, resolveElectronBinary } from "./e2e/boot.mjs"; + +const root = repositoryRoot(); +const { build } = createRequire(join(root, "packages/agent-runtime/package.json"))("esbuild"); +const temp = await mkdtemp(join(tmpdir(), "pi-destination-loading-")); +try { + await build({ + entryPoints: [join(root, "scripts/e2e/pull-requests-loading.jsx")], + outfile: join(temp, "renderer.js"), + bundle: true, + platform: "browser", + format: "esm", + jsx: "automatic", + define: { "process.env.NODE_ENV": '"production"' }, + alias: { + "@pi-desktop/i18n": join(root, "packages/i18n/src/index.ts"), + react: join(root, "apps/desktop/node_modules/react"), + "react-dom": join(root, "apps/desktop/node_modules/react-dom"), + i18next: join(root, "apps/desktop/node_modules/i18next"), + "react-i18next": join(root, "apps/desktop/node_modules/react-i18next"), + }, + nodePaths: [join(root, "apps/desktop/node_modules")], + }); + + const renderer = join(root, "apps/desktop/out/renderer"); + const appHtml = await readFile(join(renderer, "index.html"), "utf8"); + const css = [...appHtml.matchAll(/href="([^" ]+\.css)"/g)].map((match) => match[1]); + assert(css.length, "Run pnpm build:js before this test"); + await cp(join(renderer, "assets"), join(temp, "assets"), { recursive: true }); + await writeFile( + join(temp, "index.html"), + `${css.map((path) => ``).join("")}
`, + ); + await writeFile( + join(temp, "main.cjs"), + ` +const { app, BrowserWindow } = require("electron"); +const path = require("node:path"); +app.setPath("userData", path.join(__dirname, "profile")); +app.whenReady().then(async () => { + const window = new BrowserWindow({ show: false, width: 1180, height: 760, + webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } }); + window.webContents.on("console-message", (event) => console.error(event.message)); + try { + await window.loadFile(path.join(__dirname, "index.html")); + const result = await window.webContents.executeJavaScript("window.pullRequestsLoadingProbe()"); + console.log("DESTINATION_LOADING_PROBE " + JSON.stringify(result)); + app.exit(0); + } catch (error) { + console.error("DESTINATION_LOADING_PROBE " + JSON.stringify({ ok: false, error: String(error) })); + app.exit(1); + } +}); +`, + ); + + const env = { ...process.env }; + delete env.ELECTRON_RUN_AS_NODE; + const child = spawn(resolveElectronBinary(root).electronBinary, [join(temp, "main.cjs")], { + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + for (const stream of [child.stdout, child.stderr]) { + stream.on("data", (chunk) => { + output += chunk; + }); + } + const timer = setTimeout(() => child.kill("SIGKILL"), 45_000); + let code; + try { + code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + } finally { + clearTimeout(timer); + } + const line = output + .split(/\r?\n/) + .find((entry) => entry.startsWith("DESTINATION_LOADING_PROBE ")); + assert(line, `renderer returned no probe result (exit=${code}): ${output.slice(-3000)}`); + const result = JSON.parse(line.slice("DESTINATION_LOADING_PROBE ".length)); + console.log("DESTINATION_LOADING_PROBE " + JSON.stringify(result)); + assert.equal(code, 0, output.slice(-6000)); + assert.equal(result.ok, true); +} finally { + await rm(temp, { recursive: true, force: true }); +} diff --git a/scripts/e2e-transcript-render.mjs b/scripts/e2e-transcript-render.mjs index 8d6a8c3ed9..b4c2c156a7 100644 --- a/scripts/e2e-transcript-render.mjs +++ b/scripts/e2e-transcript-render.mjs @@ -133,7 +133,7 @@ app.whenReady().then(async () => { window.webContents.on("console-message", (event) => console.error(event.message)); try { await window.loadFile(path.join(__dirname, "index.html")); - const result = await window.webContents.executeJavaScript("globalThis.transcriptRenderProbe().then((render) => globalThis.transcriptRuntimeSlotProbe().then((slot) => Object.assign({}, render, { runtimeSlot: slot, ok: render.ok && slot.ok })))"); + const result = await window.webContents.executeJavaScript("globalThis.transcriptRenderProbe().then((render) => globalThis.transcriptRuntimeSlotProbe().then((slot) => globalThis.smoothTextThrottleProbe().then((smoothText) => Object.assign({}, render, { runtimeSlot: slot, smoothText, ok: render.ok && slot.ok && smoothText.ok }))))"); console.log("TRANSCRIPT_RENDER_PROBE " + JSON.stringify(result)); app.quit(); } catch (error) { @@ -183,6 +183,11 @@ app.whenReady().then(async () => { true, `runtime status slot scenario failed: ${JSON.stringify(result.runtimeSlot?.failures)}`, ); + assert.equal( + result.smoothText?.ok, + true, + `smooth text cadence scenario failed: ${JSON.stringify(result.smoothText)}`, + ); } finally { await rm(temp, { recursive: true, force: true }); } diff --git a/scripts/e2e/pull-requests-loading.jsx b/scripts/e2e/pull-requests-loading.jsx new file mode 100644 index 0000000000..9da5d7494f --- /dev/null +++ b/scripts/e2e/pull-requests-loading.jsx @@ -0,0 +1,154 @@ +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { createInstance } from "i18next"; +import { I18nextProvider } from "react-i18next"; +import { catalogs, flattenCatalog } from "@pi-desktop/i18n"; +import { IPC } from "@pi-desktop/shared"; +import { PullRequestsPage } from "../../apps/desktop/src/pages/PullRequestsPage"; +import { useAppStore } from "../../apps/desktop/src/stores/app-store"; + +const firstWorkspace = { path: "/fixtures/first", name: "First project" }; +const secondWorkspace = { path: "/fixtures/second", name: "Second project" }; +const requests = []; + +window.piDesktop = { + platform: "darwin", + on: () => () => {}, + async invoke(channel) { + if (channel !== IPC.invoke.pullsList) { + throw new Error(`Unexpected fixture IPC: ${channel}`); + } + const workspacePath = useAppStore.getState().workspace?.path; + return new Promise((resolve) => requests.push({ workspacePath, resolve })); + }, +}; + +const i18n = createInstance(); +await i18n.init({ + lng: "en", + resources: { en: { translation: flattenCatalog(catalogs.en) } }, + interpolation: { escapeValue: false }, +}); + +useAppStore.setState({ workspace: firstWorkspace }); +const container = document.createElement("div"); +document.body.append(container); +const root = createRoot(container); +flushSync(() => + root.render( + + + , + ), +); + +const frame = () => new Promise(requestAnimationFrame); +async function settle() { + await frame(); + await frame(); +} +function assert(value, message) { + if (!value) throw new Error(message); +} +async function waitFor(predicate, message) { + for (let attempt = 0; attempt < 120; attempt += 1) { + if (predicate()) return; + await frame(); + } + throw new Error(message); +} +function requestsFor(workspacePath) { + return requests.filter((request) => request.workspacePath === workspacePath); +} +function release(request, pulls) { + assert(request, "Expected a held pull request list call"); + request.resolve({ ok: true, data: { pulls } }); +} + +window.pullRequestsLoadingProbe = async () => { + await waitFor( + () => requestsFor(firstWorkspace.path).length === 1, + "First workspace request was not issued", + ); + const pending = container.querySelector('[role="status"][aria-busy="true"]'); + assert(pending, "Pending list must show its accessible loading status"); + assert( + !container.querySelector(".page-empty-icon"), + "Pending list must not look like an empty result", + ); + + flushSync(() => useAppStore.setState({ workspace: secondWorkspace })); + await waitFor( + () => requestsFor(secondWorkspace.path).length === 1, + "Second workspace request was not issued", + ); + const secondWorkspacePulls = [ + { + number: 22, + title: "Second workspace open pull request", + url: "https://example.invalid/pull/22", + author: "fixture", + isDraft: false, + headRefName: "feature/open", + baseRefName: "main", + }, + { + number: 23, + title: "Second workspace draft pull request", + url: "https://example.invalid/pull/23", + author: "fixture", + isDraft: true, + headRefName: "feature/draft", + baseRefName: "main", + }, + ]; + release(requestsFor(secondWorkspace.path)[0], secondWorkspacePulls); + await waitFor( + () => container.textContent?.includes("Second workspace open pull request"), + "Second workspace rows did not render", + ); + + const refresh = container.querySelector(".page-header .flex button"); + assert(refresh instanceof HTMLButtonElement, "Refresh button must be rendered"); + flushSync(() => refresh.click()); + await waitFor( + () => requestsFor(secondWorkspace.path).length === 2, + "Explicit refresh request was not issued", + ); + assert( + container.textContent?.includes("Second workspace open pull request"), + "Explicit refresh must keep current rows visible", + ); + release(requestsFor(secondWorkspace.path)[1], secondWorkspacePulls); + await waitFor( + () => !refresh.disabled, + "Explicit refresh did not finish", + ); + + const allTab = [...container.querySelectorAll('[role="tab"]')].find((tab) => + tab.textContent?.includes("All"), + ); + assert(allTab?.textContent?.replace(/\s+/g, "").endsWith("2"), "Filter count must match current workspace rows"); + flushSync(() => allTab.click()); + release(requestsFor(firstWorkspace.path)[0], [ + { + number: 11, + title: "Stale first workspace pull request", + url: "https://example.invalid/pull/11", + isDraft: false, + }, + ]); + await settle(); + assert( + container.textContent?.includes("Second workspace open pull request") && + container.textContent.includes("Second workspace draft pull request"), + "Completing an older request must preserve second workspace rows", + ); + assert( + !container.textContent?.includes("Stale first workspace pull request"), + "A stale request must not replace current workspace rows", + ); + + root.unmount(); + return { ok: true, requestCount: requests.length, workspaceRows: 2 }; +}; diff --git a/scripts/e2e/settings-scroll.jsx b/scripts/e2e/settings-scroll.jsx index 2647ba792d..7f504a092e 100644 --- a/scripts/e2e/settings-scroll.jsx +++ b/scripts/e2e/settings-scroll.jsx @@ -239,6 +239,10 @@ async function exerciseBrazilianPortuguese() { } window.settingsScrollProbe = async () => { await settle(); + assert( + document.activeElement === document.querySelector(".settings-search"), + "Mounting Settings must move focus to its search control", + ); const checks = []; await checkCloudSyncVisibility(); for (const theme of ["light", "dark"]) { diff --git a/scripts/e2e/transcript-render.tsx b/scripts/e2e/transcript-render.tsx index 6700acb26c..be8134e64f 100644 --- a/scripts/e2e/transcript-render.tsx +++ b/scripts/e2e/transcript-render.tsx @@ -3,6 +3,7 @@ import { turnProcessProbe } from "./turn-process"; import { transcriptStatusProbe } from "./transcript-status"; import { createRoot } from "react-dom/client"; import { flushSync } from "react-dom"; +import { useState } from "react"; import { createInstance } from "i18next"; import { I18nextProvider } from "react-i18next"; import { en } from "@pi-desktop/i18n"; @@ -11,12 +12,14 @@ import { Markdown } from "../../apps/desktop/src/components/Markdown"; import { AssistantTurn } from "../../apps/desktop/src/features/chat/transcript/AssistantTurn"; import { ChatTranscript } from "../../apps/desktop/src/features/chat/transcript/ChatTranscript"; import { buildTranscriptEntries } from "../../apps/desktop/src/lib/assistant-turns"; +import { useSmoothText } from "../../apps/desktop/src/hooks/useSmoothText"; import { useAppStore } from "../../apps/desktop/src/stores/app-store"; declare global { var __activityGroupRenders: string[]; var transcriptRenderProbe: () => Promise; var transcriptRuntimeSlotProbe: () => Promise; + var smoothTextThrottleProbe: () => Promise; } function assert(value: unknown, message: string): asserts value { @@ -651,3 +654,89 @@ globalThis.transcriptRuntimeSlotProbe = async () => { useAppStore.setState({ agentStatuses: {} }); } }; + +/** The real streaming hook must not commit above 60 Hz on a 120 Hz display. */ +globalThis.smoothTextThrottleProbe = async () => { + const originalRequestAnimationFrame = window.requestAnimationFrame; + const originalCancelAnimationFrame = window.cancelAnimationFrame; + const callbacks = new Map(); + const commits: number[] = []; + const sourceText = "streaming-fragment-".repeat(16); + let nextFrameId = 0; + let frameTime = performance.now(); + let updateSource: (value: string) => void = () => undefined; + let lastText: string | null = null; + + window.requestAnimationFrame = (callback) => { + const id = ++nextFrameId; + callbacks.set(id, callback); + return id; + }; + window.cancelAnimationFrame = (id) => { + callbacks.delete(id); + }; + + function SmoothTextFixture() { + const [source, setSource] = useState(""); + updateSource = setSource; + const visible = useSmoothText(source, source.length > 0, true); + if (visible !== lastText) { + lastText = visible; + commits.push(frameTime); + } + return
{visible}
; + } + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const yieldToEffects = () => + new Promise((resolve) => window.setTimeout(resolve, 0)); + const flushFrame = (now: number) => { + frameTime = now; + const scheduled = [...callbacks.values()]; + callbacks.clear(); + flushSync(() => { + for (const callback of scheduled) callback(now); + }); + }; + + try { + flushSync(() => root.render()); + await yieldToEffects(); + commits.length = 0; + flushSync(() => updateSource(sourceText)); + await yieldToEffects(); + assert(callbacks.size > 0, "smooth text did not schedule its first frame"); + + const start = performance.now(); + const frameInterval = 1000 / 120; + for (let frame = 1; frame <= 360; frame += 1) { + flushFrame(start + frame * frameInterval); + } + assert( + host.textContent === sourceText, + "smooth text did not reveal the complete streamed source", + ); + assert(commits.length > 1, "smooth text did not reveal progressively"); + const gaps = commits.slice(1).map((time, index) => time - commits[index]); + const minimumGapMs = Math.min(...gaps); + assert( + minimumGapMs >= 16.5, + `smooth text committed faster than 60 Hz (${minimumGapMs.toFixed(2)}ms)`, + ); + flushFrame(start + 361 * frameInterval); + assert(callbacks.size === 0, "smooth text kept scheduling frames after catching up"); + return { + ok: true, + commits: commits.length, + minimumGapMs, + idleFramesAfterCatchUp: callbacks.size, + }; + } finally { + flushSync(() => root.unmount()); + host.remove(); + window.requestAnimationFrame = originalRequestAnimationFrame; + window.cancelAnimationFrame = originalCancelAnimationFrame; + } +};