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
24 changes: 24 additions & 0 deletions docs/task-ui-simplification/change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Intent

精简 Task 列表页与详情页的视觉层级和信息密度,让核心状态、当前执行、最新结果与主要操作更容易扫描,同时保留现有数据、操作、状态限制和 Renderer/Main 边界。

风险判定:标准变更。它调整多个 Renderer 组件的用户可见结构、响应式样式与测试,但不改变 Task 状态机、持久化、IPC、Main 服务或权限边界。

# Spec

- 列表页概览固定为总数、进行中、待处理、已完成四项;后三项继续切换现有列表视图,总数只读。近七天指标、耗时、Token 与完整状态分布不再常驻展示,底层 Dashboard DTO 保持不变。
- 视图切换、搜索和项目筛选组成一条紧凑、可换行的工具栏;通知、新建、更多菜单、分页、空态、加载与重试保持可用。
- 每条任务只常驻展示标题、项目、可读状态和更新时间,不展示 workspace、耗时或 Token;长文本截断但保留 title,交互行保留明确的 focus-visible 状态。
- 详情页顶部集中返回、标题、状态和当前可用主要操作,并在足够宽度下保持 sticky。
- 当前执行和最新结果保留强调容器;项目、workspace、耗时、Token 合并为无独立卡片的紧凑元数据行。
- 需求、验证详情和历史 Run 使用分隔式折叠区域。需求仅在 backlog 默认展开;需要立即处理的验证状态可以默认展开,其余验证详情与历史默认收起。
- 最新报告、测试汇总、交付链接、错误、注意状态及全部现有操作和状态限制保持清晰可用。
- 继续使用现有主题 token、容器查询、语义化状态文本、可访问名称和 focus-visible 样式;不新增依赖或文案能力。

# Plan

1. 先更新 TaskPage、TaskDetail 与布局测试,覆盖四项概览、精简列表字段、紧凑元数据、折叠验证/需求/历史和窄容器结构,并确认旧实现失败。
2. 精简 `TaskDashboard` 和任务行展示,移除仅为已隐藏指标服务的 Renderer 计时与格式化逻辑,同时保持筛选、错误和刷新行为。
3. 重组 `TaskDetail` 的验证详情与资源元数据,保留所有操作回调和状态门控。
4. 调整 Task 专用 CSS 为紧凑工具栏、列表行、扁平详情分区和宽/窄容器响应式布局。
5. 运行 focused Renderer tests、lint、typecheck、Renderer/build 验证及 `pnpm check`;记录无法完成的原生视觉检查。
89 changes: 3 additions & 86 deletions src/features/tasks/TaskDashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,25 @@
import { formatNumber, type AppLanguage } from "../../i18n";
import { formatTaskDuration, formatTaskTokens } from "./taskMetrics";
import { taskStatusKeys } from "./taskPresentation";
import type { TaskDashboardValue, TaskStatus } from "./tasksApi";
import { formatNumber } from "../../i18n";
import type { TaskDashboardValue } from "./tasksApi";
import type { TaskListView } from "./tasksStore";

const STATUS_ORDER: TaskStatus[] = [
"backlog",
"queued",
"running",
"needs_review",
"completed",
"failed",
"cancelled",
];

interface TaskDashboardProps {
value: TaskDashboardValue | null;
loading: boolean;
error: string | null;
language: AppLanguage;
now: number;
t(key: string, values?: Record<string, string | number>): string;
onRetry(): void;
onSelectView(
view: Extract<TaskListView, "attention" | "active" | "completed">,
): void;
onSelectStatus(status: TaskStatus): void;
}

export function TaskDashboard({
value,
loading,
error,
language,
now,
t,
onRetry,
onSelectView,
onSelectStatus,
}: TaskDashboardProps) {
if (!value && loading)
return (
Expand Down Expand Up @@ -66,16 +48,11 @@ export function TaskDashboard({
);
if (!value) return null;

const liveDuration =
value.durationMs +
value.advancingRunCount * Math.max(0, now - value.measuredAt);
const maximumStatusCount = Math.max(1, ...Object.values(value.byStatus));
const metrics: Array<{
key: string;
label: string;
value: string;
view?: "attention" | "active" | "completed";
description?: string;
}> = [
{
key: "total",
Expand All @@ -93,34 +70,13 @@ export function TaskDashboard({
label: t("tasks.dashboard.attention"),
value: formatNumber(value.attention),
view: "attention",
description: t("tasks.dashboard.overlapHint"),
},
{
key: "completed",
label: t("tasks.dashboard.completed"),
value: formatNumber(value.completed),
view: "completed",
},
{
key: "createdRecent",
label: t("tasks.dashboard.createdRecent"),
value: formatNumber(value.createdLast7Days),
},
{
key: "completedRecent",
label: t("tasks.dashboard.completedRecent"),
value: formatNumber(value.completedLast7Days),
},
{
key: "duration",
label: t("tasks.dashboard.duration"),
value: formatTaskDuration(liveDuration, language),
},
{
key: "tokens",
label: t("tasks.dashboard.tokens"),
value: formatTaskTokens(value.tokenCount),
},
];

return (
Expand All @@ -130,10 +86,7 @@ export function TaskDashboard({
aria-busy={loading}
>
<div className="task-dashboard-heading-row">
<div>
<h2 id="task-dashboard-heading">{t("tasks.dashboard.heading")}</h2>
<p>{t("tasks.dashboard.globalHint")}</p>
</div>
<h2 id="task-dashboard-heading">{t("tasks.dashboard.heading")}</h2>
{loading ? (
<span role="status">{t("tasks.dashboard.refreshing")}</span>
) : null}
Expand All @@ -158,7 +111,6 @@ export function TaskDashboard({
{metric.label}
</span>
<strong>{metric.value}</strong>
{metric.description ? <small>{metric.description}</small> : null}
</>
);
return metric.view ? (
Expand All @@ -181,41 +133,6 @@ export function TaskDashboard({
);
})}
</div>
<div className="task-dashboard-distribution">
<h3>{t("tasks.dashboard.distribution")}</h3>
{value.total === 0 ? <p>{t("tasks.dashboard.empty")}</p> : null}
<ul>
{STATUS_ORDER.map((status) => {
const count = value.byStatus[status];
const label = t(taskStatusKeys[status]);
return (
<li key={status}>
<button
type="button"
onClick={() => onSelectStatus(status)}
aria-label={t("tasks.dashboard.filterStatus", {
label,
count,
})}
>
<span className="task-dashboard-status-row">
<span>{label}</span>
<strong>{formatNumber(count)}</strong>
</span>
<span className="task-dashboard-bar-track" aria-hidden="true">
<span
className={`task-dashboard-bar task-dashboard-bar-${status}`}
style={{
width: `${(count / maximumStatusCount) * 100}%`,
}}
/>
</span>
</button>
</li>
);
})}
</ul>
</div>
</section>
);
}
43 changes: 43 additions & 0 deletions src/features/tasks/TaskDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,13 @@ describe("TaskDetail", () => {
expect(screen.getByText("1h 1m 1s")).toBeInTheDocument();
expect(screen.getByText("Tokens")).toBeInTheDocument();
expect(screen.getByText("98.8K")).toBeInTheDocument();
const metadata = screen.getByRole("region", { name: "Overview" });
expect(metadata).toHaveClass("task-resource-summary");
expect(metadata.querySelectorAll(".task-resource-item")).toHaveLength(4);
const requirements = screen
.getAllByText("Requirements")[0]
?.closest("details");
expect(requirements).toHaveAttribute("open");
});

it("treats awaiting_report as running and never as completed", async () => {
Expand Down Expand Up @@ -411,6 +418,10 @@ it("shows verification evidence and updates the delivery gate switch", async ()
});
render(<TaskDetail api={api} task={verified} onChange={onChange} />);

const verificationDetails = screen
.getByText("Independent verification")
.closest("details");
expect(verificationDetails).toHaveAttribute("open");
expect(
screen.getByText("The invalid-input path is broken"),
).toBeInTheDocument();
Expand All @@ -428,6 +439,38 @@ it("shows verification evidence and updates the delivery gate switch", async ()
expect(onChange).toHaveBeenCalled();
});

it("keeps successful verification details collapsed by default", () => {
const verified = taskDetail({
...reviewTask,
verificationEnabled: true,
verification: {
id: "verification-1",
codingRunId: "run-1",
worktreeId: "worktree-1",
commitSha: "abcdef1234567890",
verdict: "pass",
summary: "All criteria passed",
evidence: { acceptanceCriteria: [], commands: [], issues: [] },
sessionId: null,
createdAt: 2,
finishedAt: 3,
},
});
render(
<TaskDetail
api={createTasksApiMock()}
task={verified}
onChange={vi.fn()}
/>,
);

const verificationDetails = screen
.getByText("Independent verification")
.closest("details");
expect(verificationDetails).not.toHaveAttribute("open");
expect(verificationDetails).toHaveClass("task-verification-details");
});

it("shows session navigation errors inline", async () => {
render(
<TaskDetail
Expand Down
Loading
Loading