Skip to content

Commit f65edf1

Browse files
authored
Merge pull request #3970 from huangruiteng/codex/completed-task-scroll
feat(dashboard): browse completed Todo history lazily
2 parents 4326799 + 81d495e commit f65edf1

13 files changed

Lines changed: 456 additions & 14 deletions
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { useEffect, useRef, useState } from "react";
2+
import { z } from "zod";
3+
import type { WorkspaceAgentTodo, WorkspaceDrawerSelection, WorkspaceGoal } from "./personal-workspace-model";
4+
import { useWorkspaceI18n } from "./i18n";
5+
6+
const pageSchema = z.object({
7+
ok: z.literal(true), total: z.number().int().nonnegative(), next_cursor: z.string().nullable(),
8+
items: z.array(z.object({ todo_id: z.string(), text: z.string(), claimed_by: z.string().nullable(), evidence: z.string().nullable(), priority: z.string().nullable(), task_class: z.string().nullable() })).max(40),
9+
});
10+
const rowHeight = 148;
11+
12+
/** Fixed-height previews keep layout/DOM cost bounded; the drawer retains full text. */
13+
export function CompletedTaskLane({ goal, agentId, seed, enabled, onSelect }: {
14+
goal: WorkspaceGoal; agentId: string; seed: WorkspaceAgentTodo[]; enabled: boolean;
15+
onSelect: (selection: WorkspaceDrawerSelection) => void;
16+
}) {
17+
const { t } = useWorkspaceI18n();
18+
const [rows, setRows] = useState(seed);
19+
const [total, setTotal] = useState(agentId === "all" ? goal.doneTodoCount ?? seed.length : seed.length);
20+
const [cursor, setCursor] = useState<string | null | undefined>(undefined);
21+
const [busy, setBusy] = useState(false);
22+
const [error, setError] = useState(false);
23+
const [expired, setExpired] = useState(false);
24+
const [viewport, setViewport] = useState({ top: 0, height: 600 });
25+
const [focused, setFocused] = useState<number | null>(null);
26+
const scroll = useRef<HTMLDivElement>(null);
27+
const request = useRef<AbortController | null>(null);
28+
const [retry, setRetry] = useState(0);
29+
useEffect(() => {
30+
const element = scroll.current;
31+
if (!element) return;
32+
const observer = new ResizeObserver(() => setViewport({ top: element.scrollTop, height: element.clientHeight }));
33+
observer.observe(element);
34+
return () => { observer.disconnect(); request.current?.abort(); };
35+
}, []);
36+
const needsPage = cursor === undefined || viewport.top + viewport.height >= rows.length * rowHeight - rowHeight * 2;
37+
useEffect(() => {
38+
if (!enabled || !needsPage || cursor === null || error || request.current) return;
39+
const controller = new AbortController();
40+
request.current = controller;
41+
setBusy(true);
42+
const query = new URLSearchParams({ goal_id: goal.goalId });
43+
if (agentId !== "all") query.set("agent_id", agentId);
44+
if (cursor) query.set("cursor", cursor);
45+
void fetch(`/api/chat/completed-todos?${query}`, { signal: controller.signal }).then(async (response) => {
46+
if (response.status === 409) setExpired(true);
47+
if (!response.ok) throw new Error("history unavailable");
48+
const page = pageSchema.parse(await response.json());
49+
if (controller.signal.aborted) return;
50+
const next = page.items.map((todo): WorkspaceAgentTodo => ({ todoId: todo.todo_id, text: todo.text, claimedBy: todo.claimed_by, evidence: todo.evidence, priority: todo.priority, taskClass: todo.task_class, done: true, status: "done" }));
51+
setRows((current) => cursor === undefined ? next : [...current, ...next.filter((todo) => !current.some((existing) => existing.todoId === todo.todoId))]);
52+
setTotal(page.total);
53+
setCursor(page.next_cursor);
54+
}).catch(() => { if (!controller.signal.aborted) setError(true); }).finally(() => {
55+
if (!controller.signal.aborted) { request.current = null; setBusy(false); }
56+
});
57+
}, [enabled, needsPage, cursor, error, retry, goal.goalId, agentId, busy]);
58+
const first = Math.max(0, Math.floor(viewport.top / rowHeight) - 3);
59+
const last = Math.min(rows.length, Math.ceil((viewport.top + viewport.height) / rowHeight) + 3);
60+
const indices = Array.from({ length: Math.max(0, last - first) }, (_, index) => first + index);
61+
if (focused !== null && focused < rows.length && !indices.includes(focused)) indices.push(focused);
62+
return <section className="personal-object-list personal-task-lane" data-testid="completed-task-lane">
63+
<header><strong><i aria-hidden="true" className="personal-kanban-dot tone-done" />{t("tasks.completed")}</strong><span>{total}</span></header>
64+
<div aria-label={t("tasks.completed")} className="personal-task-lane-scroll" ref={scroll} role="region" tabIndex={0} onScroll={(event) => setViewport({ top: event.currentTarget.scrollTop, height: event.currentTarget.clientHeight })}>
65+
<div className="personal-completed-window" style={{ height: rows.length * rowHeight }}>
66+
{indices.map((index) => {
67+
const todo = rows[index]!;
68+
return <div className="personal-task-card personal-completed-row" key={todo.todoId} style={{ top: index * rowHeight, height: rowHeight }}>
69+
<button type="button" onFocus={() => setFocused(index)} onBlur={() => setFocused(null)} onClick={() => onSelect({ kind: "todo", item: { ...todo, goalId: goal.goalId, goalTitle: goal.title, ownerLabel: todo.claimedBy ?? goal.agentLabel ?? goal.agentId } })}>
70+
<span className="is-done">✓</span><strong>{todo.text}</strong><small>{todo.claimedBy ?? goal.agentLabel ?? goal.agentId}</small>
71+
</button>
72+
</div>;
73+
})}
74+
</div>
75+
<div className="personal-completed-footer" role="status">
76+
{busy ? t("tasks.historyLoading") : error ? <><span>{t(expired ? "tasks.historyExpired" : "tasks.historyError")}</span><button type="button" onClick={() => { if (expired) { setCursor(undefined); if (scroll.current) scroll.current.scrollTop = 0; } setExpired(false); setError(false); setRetry((value) => value + 1); }}>{t("tasks.historyRetry")}</button></> : !enabled ? t("tasks.historyLocalOnly") : cursor === null ? t("tasks.historyEnd") : <button type="button" onClick={() => { if (scroll.current) scroll.current.scrollTop = rows.length * rowHeight; }}>{t("tasks.historyMore")}</button>}
77+
</div>
78+
</div>
79+
</section>;
80+
}

‎apps/presentation/dashboard/src/features/personal-workspace/goal-tasks-view.tsx‎

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
WorkspaceTimelineItem,
99
} from "./personal-workspace-model";
1010
import { localizedAttentionAge, useWorkspaceI18n } from "./i18n";
11+
import { CompletedTaskLane } from "./completed-task-lane";
1112

1213
function TaskLane({
1314
children,
@@ -91,6 +92,7 @@ function TaskLane({
9192
* Columns are states; cards open the same typed-preview drawer as the chat.
9293
*/
9394
export function GoalTasksView({
95+
historyEnabled = false,
9496
goal,
9597
items,
9698
onDraftTaskFromMessage,
@@ -101,6 +103,7 @@ export function GoalTasksView({
101103
selectedTodoId = null,
102104
userTodos,
103105
}: {
106+
historyEnabled?: boolean;
104107
goal: WorkspaceGoal;
105108
items: WorkspaceTimelineItem[];
106109
onDraftTaskFromMessage?: (message: string) => void;
@@ -263,16 +266,7 @@ export function GoalTasksView({
263266
))}
264267
{!scheduleItems.length ? <p className="personal-task-empty">{t("tasks.emptySchedules")}</p> : null}
265268
</TaskLane>
266-
<TaskLane count={Math.max(goal.doneTodoCount ?? 0, doneAgentTodos.length)} label={t("tasks.completed")} tone="done">
267-
{doneAgentTodos.map((todo) => (
268-
<button aria-pressed={selectedTodoId === todo.todoId} className={selectedTodoId === todo.todoId ? "is-selected" : undefined} key={todo.todoId} onClick={() => onSelect({ item: { ...todo, goalId: goal.goalId, goalTitle: goal.title, ownerLabel: todo.claimedBy ?? goal.agentLabel ?? goal.agentId }, kind: "todo" })} ref={selectedTodoId === todo.todoId ? (element) => { selectedTodoRef.current = element; } : undefined} type="button">
269-
<span className="is-done">✓</span><strong>{todo.text}</strong><small>{todo.claimedBy ?? goal.agentLabel ?? goal.agentId}</small>
270-
</button>
271-
))}
272-
{!doneAgentTodos.length ? <p className="personal-task-empty">{(goal.doneTodoCount ?? 0) > 0
273-
? t("tasks.completedSummary", { count: goal.doneTodoCount ?? 0 })
274-
: t("tasks.emptyCompleted")}</p> : null}
275-
</TaskLane>
269+
<CompletedTaskLane key={`${goal.goalId}:${selectedLaneId}:${historyEnabled}`} goal={goal} agentId={selectedLaneId} seed={doneAgentTodos} enabled={historyEnabled} onSelect={onSelect} />
276270
</div>
277271
{isEmpty ? (
278272
<p className="personal-task-empty">

‎apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,13 @@ const en = {
760760
"sidebar.resume": "Resume",
761761
"sidebar.resumeGoal": "Resume Goal",
762762
"sidebar.stop": "Stop",
763+
"tasks.historyLoading": "Loading history…",
764+
"tasks.historyError": "History could not be loaded.",
765+
"tasks.historyExpired": "History snapshot expired. Reload to continue.",
766+
"tasks.historyRetry": "Reload",
767+
"tasks.historyEnd": "End of completed history",
768+
"tasks.historyMore": "Load more",
769+
"tasks.historyLocalOnly": "Open this machine’s Dashboard to browse full history.",
763770
"sidebar.sortGoals": "Reorder Goals",
764771
"sidebar.dragGoal": "Drag to reorder, or use Reorder Goals",
765772
"sidebar.moveUp": "Move {goal} up",
@@ -1573,6 +1580,13 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
15731580
"sidebar.resume": "恢复",
15741581
"sidebar.resumeGoal": "恢复 Goal",
15751582
"sidebar.stop": "停止",
1583+
"tasks.historyLoading": "正在加载历史…",
1584+
"tasks.historyError": "历史加载失败,已显示的内容仍可查看。",
1585+
"tasks.historyExpired": "历史快照已过期,重新加载后可继续查看。",
1586+
"tasks.historyRetry": "重新加载",
1587+
"tasks.historyEnd": "已显示全部完成记录",
1588+
"tasks.historyMore": "加载更多",
1589+
"tasks.historyLocalOnly": "请打开这台机器的 Dashboard 查看完整历史。",
15761590
"sidebar.sortGoals": "调整 Goal 顺序",
15771591
"sidebar.dragGoal": "拖拽调整顺序,或使用列表排序按钮",
15781592
"sidebar.moveUp": "上移 {goal}",

‎apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,8 @@ assert.match(status, /recent_completed_advancement_items/, "The status schema ac
470470
assert.match(model, /doneTodoCount\??:/, "A Goal exposes the payload completed-Todo count");
471471
assert.match(dashboard, /personalAgentTodoFacts/, "Goal projection derives completion facts from the payload, not open-only item lists");
472472
assert.match(dashboard, /agentTodos:\s*\[\.\.\.goalAgentTodos,\s*\.\.\.agentTodoFacts\.recentCompleted\]/, "Recent completed Todos stay visible in the Goal board");
473-
assert.match(tasks, /Math\.max\(goal\.doneTodoCount \?\? 0, doneAgentTodos\.length\)/, "The completed column counts payload completions instead of open-only items");
473+
assert.match(tasks, /<CompletedTaskLane/, "Completed history is owned by its paginated lane");
474+
assert.match(source("./completed-task-lane.tsx"), /setTotal\(page\.total\)/, "The history count comes from the scoped full-history page, not its visible window");
474475
assert.doesNotMatch(tasks, /<span>\{doneAgentTodos\.length\}<\/span>/, "The completed column never reports a false zero");
475476
assert.match(
476477
dashboard,

‎apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1755,6 +1755,7 @@ export function PersonalWorkspacePage({
17551755
) : null}
17561756
{selectedGoal && selectedGoalTab === "tasks" ? (
17571757
<GoalTasksView
1758+
historyEnabled={!readOnly}
17581759
goal={selectedGoal}
17591760
items={items}
17601761
onDraftTaskFromMessage={readOnly ? undefined : (reply) => {

‎apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css‎

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎examples/personal-workspace-browser-smoke.mjs‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,16 @@ async function installApi(page, { goalSubagentConfigurationEnabled = true } = {}
629629
await page.route("**/api/chat/**", async (route) => {
630630
const request = route.request();
631631
const url = new URL(request.url());
632+
if (url.pathname === "/api/chat/completed-todos") {
633+
const total = url.searchParams.get("goal_id") === "progress-projection" ? 4087 : 0;
634+
const offset = Number(url.searchParams.get("cursor") || 0);
635+
const items = Array.from({ length: Math.min(40, total - offset) }, (_, position) => {
636+
const index = offset + position;
637+
return { todo_id: `todo_history_${index}`, text: index < 3 ? `Completed ${String.fromCharCode(65 + index)}` : `Completed historical Task ${index + 1}`, claimed_by: "example-agent", evidence: null, priority: null, task_class: "advancement_task" };
638+
});
639+
await route.fulfill({ json: { ok: true, total, items, next_cursor: offset + 40 < total ? String(offset + 40) : null } });
640+
return;
641+
}
632642
const periodicConfiguration = {
633643
schema_version: "periodic_report_machine_defaults_v0",
634644
enabled: true,
@@ -1877,9 +1887,33 @@ async function main() {
18771887
const progressColumn = page.locator(".personal-object-list", { hasText: "待执行 / 进行中" });
18781888
if ((await progressColumn.locator(".personal-task-card").count()) !== 2) throw new Error("Id-less long Todo was duplicated across compact and full projections");
18791889
const completedColumn = page.locator(".personal-object-list", { hasText: "已完成" }).last();
1880-
await completedColumn.getByText("42", { exact: true }).waitFor({ state: "visible" });
1890+
const taskLaneScrollers = page.locator('.personal-task-kanban .personal-task-lane-scroll');
1891+
if (await taskLaneScrollers.count() !== 4) throw new Error('Every desktop Task lane must own a scroll region');
1892+
for (let laneIndex = 0; laneIndex < 4; laneIndex += 1) {
1893+
if (await taskLaneScrollers.nth(laneIndex).evaluate(element => getComputedStyle(element).overflowY) !== 'auto') {
1894+
throw new Error(`Task lane ${laneIndex + 1} does not support independent scrolling`);
1895+
}
1896+
}
1897+
await completedColumn.getByText("4087", { exact: true }).waitFor({ state: "visible" });
18811898
await completedColumn.getByText("Completed A", { exact: true }).waitFor({ state: "visible" });
18821899
if (await completedColumn.getByText("Completed Monitor", { exact: true }).count()) throw new Error("Completed continuous monitor leaked into the completed Tasks column");
1900+
const historyScroll = completedColumn.locator('.personal-task-lane-scroll');
1901+
for (let batch = 1; batch < 103; batch += 1) {
1902+
const response = page.waitForResponse(response => response.url().includes('/api/chat/completed-todos?') && response.url().includes(`cursor=${batch * 40}`));
1903+
await historyScroll.evaluate(element => { element.scrollTop = element.scrollHeight; });
1904+
await response;
1905+
await page.waitForFunction(minimum => {
1906+
const window = document.querySelector('[data-testid="completed-task-lane"] .personal-completed-window');
1907+
return window && Number.parseFloat(window.style.height) >= minimum;
1908+
}, Math.min(4087, (batch + 1) * 40) * 148);
1909+
if (await completedColumn.locator('.personal-completed-row').count() > 20) throw new Error('Completed history DOM grew with accumulated pages');
1910+
}
1911+
await historyScroll.evaluate(element => { element.scrollTop = element.scrollHeight; });
1912+
await completedColumn.getByText('Completed historical Task 4087', { exact: true }).waitFor();
1913+
await completedColumn.getByText('已显示全部完成记录', { exact: true }).waitFor();
1914+
await page.screenshot({ path: resolve(outputDir, 'completed-history-4087.png'), fullPage: false, animations: 'disabled' });
1915+
await historyScroll.evaluate(element => { element.scrollTop = 0; });
1916+
await completedColumn.getByText('Completed A', { exact: true }).waitFor();
18831917
await page.locator(".personal-goal-link", { hasText: "Multi Agent Projection" }).click();
18841918
const multiAgentHeader = await page.locator(".personal-channel-title p").innerText();
18851919
if (!multiAgentHeader.includes("2 个工作 Agent") || multiAgentHeader.includes("codex-older-lane ·")) {

0 commit comments

Comments
 (0)