Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion apps/desktop/src/features/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? (
<Sidebar
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/features/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,13 @@ export function SettingsPage() {
if (activeExtension) setActiveExtension(null);
}
const contentRef = useRef<HTMLDivElement>(null);
const settingsSearchRef = useRef<HTMLInputElement>(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;
Expand Down Expand Up @@ -283,6 +288,7 @@ export function SettingsPage() {
<div className="settings-search-wrap no-drag">
<IconSearch size={14} />
<input
ref={settingsSearchRef}
className="settings-search"
value={query}
onChange={(e) => setQuery(e.target.value)}
Expand Down
27 changes: 23 additions & 4 deletions apps/desktop/src/hooks/useSmoothText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,14 @@ export function useSmoothText(
const revealedRef = useRef(source.length);
const rafRef = useRef<number | null>(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);
Expand All @@ -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),
Expand All @@ -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;
Expand All @@ -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 () => {
Expand Down
53 changes: 38 additions & 15 deletions apps/desktop/src/pages/PullRequestsPage.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -11,47 +11,63 @@ 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);
const sendPrompt = useAppStore((s) => s.sendPrompt);
const [pulls, setPulls] = useState<PullRequestSummary[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [loadedWorkspacePath, setLoadedWorkspacePath] = useState<string | null>(null);
const [filter, setFilter] = useState<Filter>("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 (
<div className="route-scroll">
Expand Down Expand Up @@ -117,14 +133,21 @@ export function PullRequestsPage() {
{t("project.open")}
</Button>
</Panel>
) : pageLoading && visiblePulls.length === 0 ? (
<div className="page-empty page-card" role="status" aria-busy="true">
<span className="route-pending-indicator" aria-hidden />
<span className="mt-3 text-md text-text-secondary">
{t("app.loadingView")}
</span>
</div>
) : filtered.length === 0 ? (
<Panel className="page-card page-empty">
<div className="page-empty-icon">
<IconPullRequest size={20} />
</div>
<div className="text-base-plus font-medium">{t("pulls.emptyTitle")}</div>
{error && error !== "NO_WORKSPACE" ? (
<div className="mt-2 max-w-md text-md text-text-secondary">{error}</div>
{visibleError && visibleError !== "NO_WORKSPACE" ? (
<div className="mt-2 max-w-md text-md text-text-secondary">{visibleError}</div>
) : null}
</Panel>
) : (
Expand Down
32 changes: 32 additions & 0 deletions apps/desktop/test/app-shell-settings-accessibility.test.mjs
Original file line number Diff line number Diff line change
@@ -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(
/<div\s+className="app-chat-shell"[\s\S]*?>/,
)?.[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<HTMLInputElement>\(null\)/);
assert.match(
settingsPage,
/settingsSearchRef\.current\?\.focus\(\{ preventScroll: true \}\)/,
);
assert.match(settingsPage, /<input\s+ref=\{settingsSearchRef\}/);
});
28 changes: 28 additions & 0 deletions apps/desktop/test/pull-requests-loading-state.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";

const page = await readFile(
new URL("../src/pages/PullRequestsPage.tsx", import.meta.url),
"utf8",
);

test("pull request loading is distinct from the empty result state", () => {
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;/);
});
4 changes: 3 additions & 1 deletion apps/desktop/test/sidebar-settings-return.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ test("Settings hides the mounted chat shell and keeps its portal layers out of v
assert.match(appShell, /<PortalVisibilityProvider visible=\{page !== "settings"\}>/);
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(/<div\s+className="app-chat-shell"[\s\S]*?>/)?.[0];
assert.ok(chatShell, "chat shell must exist");
assert.doesNotMatch(chatShell, /aria-hidden/);
assert.match(appShell, /className="app-chat-shell"[\s\S]*?<ChatSurface visible=\{page === "chat"\} \/>/);
assert.match(appShell, /\{page === "settings" \? \([\s\S]*?<SettingsPage \/>/);
assert.match(appShell, /<ChatSurface visible=\{page === "chat"\} \/>/);
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/test/smooth-text-throttle.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
});
5 changes: 4 additions & 1 deletion docs/spec/04-ux/01-ui-ia.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
40 changes: 37 additions & 3 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down
4 changes: 3 additions & 1 deletion docs/zh-CN/spec/04-ux/01-ui-ia.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,9 @@
### 3. 3 拉取请求
带计数的分段 Open/Draft/All 过滤器;行带有图标板、数字、
标题、状态徽章、分支元、外部链接和“与代理一起审核”
(创建聊天回合)。需要活动工作区和 `gh`。
(创建聊天回合)。需要活动工作区和 `gh`。当前工作区的列表加载时,显示本地化
状态而不是空结果;手动刷新时保留现有行。切换工作区后隐藏旧工作区的行,并忽略
过期请求结果。

### 3. 4 预定
任务与运行记录两个视图,支持创建、编辑、暂停、启用和确认删除。表单可为每个任务单独选择项目、权限和模型。周期保留下拉选择,并与时分统一为自定义主题菜单。每小时按一小时间隔执行,不显示时间选择;保存、启用、启动或上次自动准入后重新计时。
Expand Down
Loading
Loading