From b30b9a3bc10f50cfd5a66c774874f1cd5fa56b51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 7 Jul 2026 12:16:36 -0400 Subject: [PATCH] Work list titles clip instead of colliding; timeline becomes segmented status bars per goal and project --- src/renderer/src/pages/WorkPage.tsx | 117 +---------------- src/renderer/src/pages/work/ListView.tsx | 77 +++++++++++ src/renderer/src/pages/work/TimelineView.tsx | 131 +++++++++++++++++++ 3 files changed, 211 insertions(+), 114 deletions(-) create mode 100644 src/renderer/src/pages/work/ListView.tsx create mode 100644 src/renderer/src/pages/work/TimelineView.tsx diff --git a/src/renderer/src/pages/WorkPage.tsx b/src/renderer/src/pages/WorkPage.tsx index 89c4a55d..4bd7be7e 100644 --- a/src/renderer/src/pages/WorkPage.tsx +++ b/src/renderer/src/pages/WorkPage.tsx @@ -20,7 +20,9 @@ import { GoalDialog } from "./work/GoalDialog"; import { ProjectDialog } from "./work/ProjectDialog"; import { TaskDetailDialog } from "./work/TaskDetailDialog"; import { ProjectOverview } from "./work/ProjectOverview"; -import { canRun, latestTaskRun, nextStatus, prettyRunState, prettyStatus, providerFor, runTone } from "./work/task-helpers"; +import { ListView } from "./work/ListView"; +import { TimelineView } from "./work/TimelineView"; +import { canRun, latestTaskRun, nextStatus, prettyRunState, providerFor, runTone } from "./work/task-helpers"; import type { PageProps } from "./types"; import { activeOrgId } from "./types"; @@ -268,119 +270,6 @@ function BoardView({ ); } -function ListView({ - tasks, - agents, - projects, - providers, - onRun, - onOpen, -}: { - tasks: TaskRecord[]; - agents: AgentRecord[]; - projects: ProjectRecord[]; - providers: ProviderRecord[]; - onRun: (task: TaskRecord) => void; - onOpen: (task: TaskRecord) => void; -}) { - return ( -
-
-
Title
Status
Priority
Assignee
-
- {tasks.map((task, index) => { - const assignee = agents.find((agent) => agent.id === task.assigneeAgentId) ?? null; - const provider = providerFor(assignee, providers); - const project = projects.find((p) => p.id === task.projectId) ?? null; - const last = index === tasks.length - 1; - return ( -
onOpen(task)} - onKeyDown={(event) => { if (event.key === "Enter") onOpen(task); }} - style={{ gridTemplateColumns: "minmax(0, 3fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.2fr) 88px", borderBottom: last ? "none" : "1px solid var(--border-soft)" }} - > -
- {task.risk === "high" && } - {task.title} - {project && {project.name}} -
-
{prettyStatus(task.status)}
-
{task.priority}
-
- {assignee ? ( -
- - {assignee.name} -
- ) : ( - — - )} -
-
event.stopPropagation()}> - {assignee && onRun(task)} disabled={!canRun(provider)} />} - onOpen(task)} /> -
-
- ); - })} -
- ); -} - -function TimelineView({ tasks, goals, projects }: { tasks: TaskRecord[]; goals: GoalRecord[]; projects: ProjectRecord[] }) { - const unlinked = tasks.filter((t) => !t.goalId); - return ( -
-
By goal
- {goals.map((goal) => { - const linked = tasks.filter((task) => task.goalId === goal.id); - const done = linked.filter((task) => task.status === TASK_STATUS.DONE).length; - const pct = linked.length === 0 ? 0 : Math.round((done / linked.length) * 100); - const goalProjects = projects.filter((p) => p.goalId === goal.id); - return ( -
-
- {goal.title} - {done} / {linked.length} -
-
-
-
- {goalProjects.length > 0 && ( -
- {goalProjects.map((p) => { - const projectTasks = linked.filter((t) => t.projectId === p.id); - const projectDone = projectTasks.filter((t) => t.status === TASK_STATUS.DONE).length; - return ( - - {truncate(p.name, 24)} {projectDone}/{projectTasks.length} - - ); - })} -
- )} -
- ); - })} - {unlinked.length > 0 && ( -
-
- Unlinked tasks - {unlinked.length} -
-
- )} -
- ); -} - function TaskCard({ task, assignee, diff --git a/src/renderer/src/pages/work/ListView.tsx b/src/renderer/src/pages/work/ListView.tsx new file mode 100644 index 00000000..ffb1e4d0 --- /dev/null +++ b/src/renderer/src/pages/work/ListView.tsx @@ -0,0 +1,77 @@ +import { Avatar, IconButton, Pill } from "../../shared/ui"; +import type { AgentRecord, ProjectRecord, ProviderRecord, TaskRecord } from "../../../../shared/domain"; +import { canRun, prettyStatus, providerFor } from "./task-helpers"; + +const GRID = "minmax(0, 3fr) 110px 96px minmax(0, 1.2fr) 88px"; + +export function ListView({ + tasks, + agents, + projects, + providers, + onRun, + onOpen, +}: { + tasks: TaskRecord[]; + agents: AgentRecord[]; + projects: ProjectRecord[]; + providers: ProviderRecord[]; + onRun: (task: TaskRecord) => void; + onOpen: (task: TaskRecord) => void; +}) { + return ( +
+
+
Title
Status
Priority
Assignee
+
+ {tasks.map((task, index) => { + const assignee = agents.find((agent) => agent.id === task.assigneeAgentId) ?? null; + const provider = providerFor(assignee, providers); + const project = projects.find((p) => p.id === task.projectId) ?? null; + const last = index === tasks.length - 1; + return ( +
onOpen(task)} + onKeyDown={(event) => { if (event.key === "Enter") onOpen(task); }} + style={{ gridTemplateColumns: GRID, borderBottom: last ? "none" : "1px solid var(--border-soft)" }} + > +
+ {task.risk === "high" && } + + {task.title} + + {project && {truncate(project.name, 24)}} +
+
{prettyStatus(task.status)}
+
{task.priority}
+
+ {assignee ? ( +
+ + + {assignee.name} + +
+ ) : ( + — + )} +
+
event.stopPropagation()}> + {assignee && onRun(task)} disabled={!canRun(provider)} />} + onOpen(task)} /> +
+
+ ); + })} +
+ ); +} + +function truncate(value: string, max: number): string { + if (value.length <= max) return value; + return `${value.slice(0, max - 1)}…`; +} diff --git a/src/renderer/src/pages/work/TimelineView.tsx b/src/renderer/src/pages/work/TimelineView.tsx new file mode 100644 index 00000000..6a2a0c46 --- /dev/null +++ b/src/renderer/src/pages/work/TimelineView.tsx @@ -0,0 +1,131 @@ +import { TASK_STATUS, type GoalRecord, type ProjectRecord, type TaskRecord, type TaskStatus } from "../../../../shared/domain"; + +const SEGMENTS: ReadonlyArray<{ id: TaskStatus; label: string; tone: string }> = [ + { id: TASK_STATUS.DONE, label: "Done", tone: "var(--green)" }, + { id: TASK_STATUS.REVIEW, label: "In review", tone: "var(--amber)" }, + { id: TASK_STATUS.IN_PROGRESS, label: "In progress", tone: "var(--accent)" }, + { id: TASK_STATUS.BACKLOG, label: "Backlog", tone: "var(--panel-3)" }, +]; + +/** + * Execution timeline by goal: each goal carries a segmented bar of its tasks + * by status (done → review → in progress → backlog), then one row per + * project with the same bar at project scope. + */ +export function TimelineView({ tasks, goals, projects }: { tasks: TaskRecord[]; goals: GoalRecord[]; projects: ProjectRecord[] }) { + const unlinked = tasks.filter((task) => !task.goalId); + return ( +
+ {goals.map((goal) => { + const linked = tasks.filter((task) => task.goalId === goal.id); + const goalProjects = projects.filter((project) => project.goalId === goal.id); + return ( +
+
+ + {goal.title} + + +
+ + + {goalProjects.length > 0 && ( +
+ {goalProjects.map((project) => { + const projectTasks = linked.filter((task) => task.projectId === project.id); + return ( +
+ + {project.name} + + + +
+ ); + })} +
+ )} +
+ ); + })} + {unlinked.length > 0 && ( +
+
+ Unlinked tasks + +
+ + +
+ )} +
+ ); +} + +function counts(tasks: TaskRecord[]): Map { + const map = new Map(); + for (const segment of SEGMENTS) map.set(segment.id, 0); + for (const task of tasks) map.set(task.status, (map.get(task.status) ?? 0) + 1); + return map; +} + +function StatusBar({ tasks, height, style }: { tasks: TaskRecord[]; height: number; style?: React.CSSProperties }) { + const byStatus = counts(tasks); + if (tasks.length === 0) { + return
; + } + return ( +
+ {SEGMENTS.map((segment) => { + const count = byStatus.get(segment.id) ?? 0; + if (count === 0) return null; + return ( + + ); + })} +
+ ); +} + +function Legend({ tasks }: { tasks: TaskRecord[] }) { + const byStatus = counts(tasks); + return ( +
+ {SEGMENTS.map((segment) => ( + + + {byStatus.get(segment.id) ?? 0} + {segment.label} + + ))} +
+ ); +} + +function CompletionReadout({ tasks }: { tasks: TaskRecord[] }) { + const done = tasks.filter((task) => task.status === TASK_STATUS.DONE).length; + return ( + + {done} / {tasks.length} + + ); +}