Skip to content

Commit 6af9215

Browse files
committed
fix(workspace): preserve unknown totals and queued state
Signed-off-by: song <liusongstep@gmail.com>
1 parent 3054cdc commit 6af9215

17 files changed

Lines changed: 128 additions & 27 deletions

File tree

‎apps/presentation/dashboard/smoke/status-projection-contract-smoke.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { z } from "zod";
33
import {
44
parseStatusPayload,
55
todoItemSchema,
6+
todoGroupSchema,
7+
projectAssetTodoSummarySchema,
68
todoIndexItemSchema,
79
periodicReportIndexItemSchema,
810
periodicReportIndexResponseSchema,
@@ -78,6 +80,15 @@ function goal(id: string, activation: "active" | "stopped") {
7880
};
7981
}
8082

83+
// A status source may omit totals while still returning a useful bounded list.
84+
// Preserve that absence before the workspace adapter and typed digest see it.
85+
for (const [schema, field] of [[todoGroupSchema, "done_count"], [projectAssetTodoSummarySchema, "done"]] as const) {
86+
const missing = schema.parse({ items: [] });
87+
equal((missing as Record<string, unknown>)[field], undefined, "Missing completion total stays unknown");
88+
const measured = schema.parse({ items: [], [field]: 0 });
89+
equal((measured as Record<string, unknown>)[field], 0, "Measured zero is retained");
90+
}
91+
8192
// Native Todo identity is the stable todo_id; a Markdown source index is only
8293
// a legacy display coordinate. Both forms must survive a full status parse.
8394
const nativeTodo = { done: false, text: "Review public evidence", todo_id: "todo_native" };

‎apps/presentation/dashboard/smoke/todo-ownership-smoke.mjs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { todoItemSchema } from "../src/data/status.ts";
77
import { workspaceAgentTodoFromItem } from "../src/features/personal-workspace/personal-workspace-model.ts";
88
import { ContextDrawer } from "../src/features/personal-workspace/context-drawer.tsx";
99
import { WorkspaceI18nProvider } from "../src/features/personal-workspace/i18n.tsx";
10+
import { RunRow } from "../src/features/personal-workspace/cards/run-row.tsx";
1011

1112
// A Goal's default executor is not evidence that a Todo has been claimed.
1213
// Exercise the real consumers, including retained history and stale labels.
@@ -56,6 +57,20 @@ try {
5657
assert.ok(drawer.includes(`<dt>${owner}</dt><dd>${expected}</dd>`), "A stale display label cannot invent an absent claim");
5758
assert.equal(todo.claimedBy, claim, "Rendering never mutates the source claim");
5859
}
60+
const todo = {todoId: "run-task", text: "Inspect execution", done: false, status: "open"};
61+
const goal = {goalId: "run-goal", title: "Execution facts", agentTodos: [todo]};
62+
for (const [status, en, zh] of [
63+
["queued", "Scheduled", "已安排"], ["running", "Running", "执行中"],
64+
["waiting", "Waiting", "等待条件"], ["failed", "Needs review", "需检查"],
65+
["interrupted", "Interrupted", "已中断"], ["completed", "Completed", "已完成"],
66+
]) {
67+
const run = {todoId: todo.todoId, goalId: goal.goalId, status, title: todo.text, agentLabel: "Worker"};
68+
const board = render(GoalTasksView, {goal, items: [{kind: "run", id: "execution", run}], userTodos: [], onSelect() {}});
69+
const timeline = render(RunRow, {run, onSelect() {}});
70+
const expected = locale === "en" ? en : zh;
71+
assert.ok(board.includes(`personal-task-session-status">${expected}</span>`), `Task cards preserve ${status}`);
72+
assert.ok(timeline.includes(expected), `Timeline and task cards agree for ${status}`);
73+
}
5974
}
6075
} finally {
6176
if (originalWindow === undefined) delete globalThis.window;

‎apps/presentation/dashboard/src/data/status.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export const todoGroupSchema = z.object({
116116
source_section: z.string().optional().nullable(),
117117
total_count: z.number().optional().default(0),
118118
open_count: z.number().optional(),
119-
done_count: z.number().optional().default(0),
119+
done_count: z.number().optional(),
120120
advancement_done_count: z.number().optional(),
121121
items: z.array(todoItemSchema).optional().default([]),
122122
deferred_items: z.array(todoItemSchema).optional(),
@@ -258,7 +258,7 @@ export const goalChannelNotificationProjectionSchema = z.object({
258258
export const projectAssetTodoSummarySchema = z.object({
259259
source_section: z.string().optional().nullable(),
260260
open: z.number().optional(),
261-
done: z.number().optional().default(0),
261+
done: z.number().optional(),
262262
total: z.number().optional().default(0),
263263
advancement_done_count: z.number().optional(),
264264
next: z.string().optional().nullable(),

‎apps/presentation/dashboard/src/features/personal-workspace/cards/run-row.tsx‎

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,7 @@
11
import { Bot, ChevronRight, LoaderCircle } from "lucide-react";
22

33
import type { WorkspaceRun } from "../personal-workspace-model";
4-
import { useWorkspaceI18n, type WorkspaceMessageKey } from "../i18n";
5-
6-
const runStatusKey: Record<WorkspaceRun["status"], WorkspaceMessageKey> = {
7-
completed: "runs.completed",
8-
failed: "runs.failed",
9-
interrupted: "runs.interrupted",
10-
queued: "runs.queued",
11-
running: "runs.running",
12-
waiting: "runs.waiting",
13-
};
4+
import { localizedSessionStatus, useWorkspaceI18n } from "../i18n";
145

156
export function RunRow({ onSelect, run, showGoal = true }: { onSelect: () => void; run: WorkspaceRun; showGoal?: boolean }) {
167
const { t } = useWorkspaceI18n();
@@ -20,7 +11,7 @@ export function RunRow({ onSelect, run, showGoal = true }: { onSelect: () => voi
2011
<span className="personal-row-copy"><small>{showGoal ? `${run.goalTitle} · ` : ""}{run.agentLabel}</small><strong>{run.title}</strong>{run.latestActivity !== run.title ? <small>{run.latestActivity}</small> : null}</span>
2112
<span className={`personal-row-status is-${run.status}`}>
2213
{run.status === "running" ? <LoaderCircle className="personal-spin" size={14} /> : null}
23-
{t(runStatusKey[run.status])}
14+
{localizedSessionStatus(run.status, t)}
2415
</span>
2516
<ChevronRight size={17} />
2617
</button>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
.goal-vital.is-danger .goal-vital-value { color: var(--pw-red); }
3030
.goal-vital.is-recede .goal-vital-value { color: var(--pw-faint); font-weight: 500; }
3131
.goal-vital-partial { color: var(--pw-amber); }
32+
.goal-vital-qualifier { display: block; color: var(--pw-amber); font-size: var(--text-caption); line-height: var(--leading-caption); }
3233
.goal-vital-swap { display: inline-block; min-width: 0; overflow: hidden; text-overflow: ellipsis; animation: goal-value-swap var(--motion-base) var(--ease-out); }
3334
.goal-vitals-scope { align-self: center; padding-left: 12px; color: var(--pw-faint); font-size: var(--text-caption); line-height: var(--leading-caption); white-space: nowrap; }
3435

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ export function GoalVitals({ goal, items, onFocusRegion, userTodos }: {
166166
t("vitals.workOpen", { count: work.open }),
167167
work.classes.blocked ? t("vitals.workBlocked", { count: work.classes.blocked }) : null,
168168
work.done === null ? t("vitals.workDoneUnknown") : null,
169+
work.coverage === "unknown" ? t("vitals.workCoverageUnknown") : null,
169170
].filter(Boolean).join(" · ");
170171
const ribbonTotal = ribbonOrder.reduce((sum, name) => sum + work.classes[name], 0);
171172

@@ -185,8 +186,11 @@ export function GoalVitals({ goal, items, onFocusRegion, userTodos }: {
185186
<Vital label={t("vitals.work")} onClick={() => onFocusRegion("tasks")} valueKey={`${goal.goalId}:${work.done}:${work.total}:${work.open}`}
186187
sub={work.coverage === "partial" && work.projectedOpen !== null
187188
? <span className="goal-vital-partial">{t("vitals.workPartial", { listed: work.listedOpen, projected: work.projectedOpen })}</span> : workSub}
188-
extra={ribbonTotal > 0 ? <span aria-hidden className="goal-work-ribbon">{ribbonOrder.map((name) => work.classes[name]
189-
? <i className={`is-${name}`} key={name} style={{ flexGrow: work.classes[name] }} /> : null)}</span> : null}>
189+
extra={<>
190+
{work.done === null || work.coverage !== "complete" ? <span className="goal-vital-qualifier">{t("vitals.workTotalUnknown")}</span> : null}
191+
{work.total !== null && ribbonTotal > 0 ? <span aria-hidden className="goal-work-ribbon">{ribbonOrder.map((name) => work.classes[name]
192+
? <i className={`is-${name}`} key={name} style={{ flexGrow: work.classes[name] }} /> : null)}</span> : null}
193+
</>}>
190194
{work.total === null ? work.open : <span>{work.done}<small>/{work.total}</small></span>}
191195
</Vital>
192196
<Vital label={t("vitals.needsYou")} onClick={() => onFocusRegion("attention")} valueKey={`${goal.goalId}:${needs.availability}:${needs.availability === "ok" ? needs.blocking : ""}`}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {
77
WorkspaceModel,
88
WorkspaceTimelineItem,
99
} from "./personal-workspace-model";
10-
import { localizedAttentionAge, useWorkspaceI18n } from "./i18n";
10+
import { localizedAttentionAge, localizedSessionStatus, useWorkspaceI18n } from "./i18n";
1111
import { CompletedTaskLane } from "./completed-task-lane";
1212
import { MarkdownText } from "./markdown";
1313
import { isGoalWorkTodo } from "../../../../../../loopx/control_plane/presentation/goal_workspace_digest.js";
@@ -256,7 +256,7 @@ export function GoalTasksView({
256256
<small>
257257
{todo.priority ? <span className={`personal-priority-badge is-${todo.priority.toLowerCase()}`}>{todo.priority}</span> : null}
258258
{todo.status === "blocked" ? <span className="personal-priority-badge is-blocked">{t("tasks.blocked")}</span> : null}
259-
{execution ? <span className="personal-task-session-status">{execution.status === "running" || execution.status === "queued" ? t("runs.running") : execution.status === "failed" ? t("tasks.sessionError") : t("common.waiting")}</span> : null}
259+
{execution ? <span className="personal-task-session-status">{localizedSessionStatus(execution.status, t)}</span> : null}
260260
{todo.status === "deferred" ? <span className="personal-task-session-status">{t("drawer.taskStatusDeferred")}</span> : !execution ? <span className="personal-task-session-status">{t("tasks.waiting")}</span> : null}
261261
<span>{enriched.ownerLabel}</span>
262262
</small>

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,6 +1389,8 @@ const en = {
13891389
"vitals.work": "Work",
13901390
"vitals.workOpen": "{count} open",
13911391
"vitals.workDoneUnknown": "done not reported",
1392+
"vitals.workCoverageUnknown": "listed work · total coverage unknown",
1393+
"vitals.workTotalUnknown": "Total unknown",
13921394
"vitals.workRunning": "{count} running",
13931395
"vitals.workBlocked": "{count} blocked",
13941396
"vitals.workPartial": "partial · {listed} of {projected} open listed",
@@ -2787,6 +2789,8 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
27872789
"vitals.work": "工作",
27882790
"vitals.workOpen": "{count} 项未完成",
27892791
"vitals.workDoneUnknown": "未报告完成数",
2792+
"vitals.workCoverageUnknown": "当前所列工作 · 总体覆盖未知",
2793+
"vitals.workTotalUnknown": "总数未知",
27902794
"vitals.workRunning": "{count} 项执行中",
27912795
"vitals.workBlocked": "{count} 项受阻",
27922796
"vitals.workPartial": "部分 · 已列出 {projected} 项中的 {listed} 项",
@@ -2894,6 +2898,7 @@ export function localizedSessionStatus(status: string | undefined, t: WorkspaceT
28942898
ready: "runs.ready",
28952899
resume_failed: "runs.resumeFailed",
28962900
running: "runs.running",
2901+
interrupted: "runs.interrupted",
28972902
waiting: "runs.waiting",
28982903
};
28992904
return status ? (keyByStatus[status] ? t(keyByStatus[status]) : status) : t("runs.unknown");

‎apps/presentation/dashboard/src/views/dashboard-page.tsx‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -388,10 +388,9 @@ function todosFromProjectAssetSummary(
388388
if (summary?.items && (summary.items.length > 0 || (role === "user" && summary.open !== undefined))) {
389389
return {
390390
advancement_done_count: summary.advancement_done_count ?? fallback?.advancement_done_count,
391-
done_count: summary.done ?? summary.items.filter((item) => item.done).length,
391+
done_count: summary.done ?? fallback?.done_count,
392392
items: summary.items,
393-
open_count: summary.open ?? fallback?.open_count
394-
?? (role === "user" ? undefined : summary.items.filter((item) => !item.done).length),
393+
open_count: summary.open ?? fallback?.open_count,
395394
total_count: summary.total ?? summary.items.length,
396395
};
397396
}
@@ -464,7 +463,7 @@ type PersonalGoalItem = {
464463
agentId: string;
465464
agentSentence: string;
466465
agentTodos: WorkspaceAgentTodo[];
467-
doneTodoCount: number;
466+
doneTodoCount?: number;
468467
openTodoCount?: number;
469468
goalId: string;
470469
latestActivity?: string;
@@ -799,7 +798,7 @@ function mergePersonalAgentTodos(
799798
* recent-completed lane. Queue items may also include completed Todos.
800799
*/
801800
function personalAgentTodoFacts(row: GoalDirectoryRow): {
802-
doneTodoCount: number;
801+
doneTodoCount?: number;
803802
nextTodoText: string | null;
804803
recentCompleted: WorkspaceAgentTodo[];
805804
} {
@@ -811,8 +810,9 @@ function personalAgentTodoFacts(row: GoalDirectoryRow): {
811810
?? assetTodos?.done
812811
?? queueTodos?.done_count
813812
?? null;
814-
const doneFromItems = items.filter((todo) => todo.done && todo.status !== "deferred").length;
815-
const doneTodoCount = Math.max(doneFromCount ?? 0, doneFromItems);
813+
const doneFromItems = items.filter((todo) => todo.done && todo.status !== "deferred" && todo.task_class !== "continuous_monitor").length;
814+
// Items are bounded observations. They cannot supply a missing whole-Goal total.
815+
const doneTodoCount = doneFromCount === null ? undefined : Math.max(doneFromCount, doneFromItems);
816816
const seenTodoIds = new Set(
817817
items
818818
.map((todo) => todo.todo_id?.trim())
121 KB
Loading

0 commit comments

Comments
 (0)