diff --git a/apps/desktop/src/features/app/AppShell.tsx b/apps/desktop/src/features/app/AppShell.tsx index 20ec725df..bb1f32c66 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 be0a1ff9d..bb0444fd6 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 88c57d0d4..dfcb4f4f3 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 000000000..467aae0a3 --- /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 d14e26a1c..514462562 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 000000000..34fab6ab1 --- /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 8ac25b7b0..a6266afac 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 69718f75a..78b0f169f 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 3e4e1a2a1..326ed93d8 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 561f282e4..5eb4f4533 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 b16052d71..dfabf9fc8 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 33aaa6005..999780655 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 000000000..8d8b3796b --- /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 8d6a8c3ed..b4c2c156a 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 000000000..9da5d7494 --- /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 2647ba792..7f504a092 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 6700acb26..be8134e64 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; + } +};