Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { z } from "zod";
import {
parseStatusPayload,
todoItemSchema,
todoGroupSchema,
projectAssetTodoSummarySchema,
todoIndexItemSchema,
periodicReportIndexItemSchema,
periodicReportIndexResponseSchema,
Expand Down Expand Up @@ -78,6 +80,15 @@ function goal(id: string, activation: "active" | "stopped") {
};
}

// A status source may omit totals while still returning a useful bounded list.
// Preserve that absence before the workspace adapter and typed digest see it.
for (const [schema, field] of [[todoGroupSchema, "done_count"], [projectAssetTodoSummarySchema, "done"]] as const) {
const missing = schema.parse({ items: [] });
equal((missing as Record<string, unknown>)[field], undefined, "Missing completion total stays unknown");
const measured = schema.parse({ items: [], [field]: 0 });
equal((measured as Record<string, unknown>)[field], 0, "Measured zero is retained");
}

// Native Todo identity is the stable todo_id; a Markdown source index is only
// a legacy display coordinate. Both forms must survive a full status parse.
const nativeTodo = { done: false, text: "Review public evidence", todo_id: "todo_native" };
Expand Down
15 changes: 15 additions & 0 deletions apps/presentation/dashboard/smoke/todo-ownership-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { todoItemSchema } from "../src/data/status.ts";
import { workspaceAgentTodoFromItem } from "../src/features/personal-workspace/personal-workspace-model.ts";
import { ContextDrawer } from "../src/features/personal-workspace/context-drawer.tsx";
import { WorkspaceI18nProvider } from "../src/features/personal-workspace/i18n.tsx";
import { RunRow } from "../src/features/personal-workspace/cards/run-row.tsx";

// A Goal's default executor is not evidence that a Todo has been claimed.
// Exercise the real consumers, including retained history and stale labels.
Expand Down Expand Up @@ -56,6 +57,20 @@ try {
assert.ok(drawer.includes(`<dt>${owner}</dt><dd>${expected}</dd>`), "A stale display label cannot invent an absent claim");
assert.equal(todo.claimedBy, claim, "Rendering never mutates the source claim");
}
const todo = {todoId: "run-task", text: "Inspect execution", done: false, status: "open"};
const goal = {goalId: "run-goal", title: "Execution facts", agentTodos: [todo]};
for (const [status, en, zh] of [
["queued", "Scheduled", "已安排"], ["running", "Running", "执行中"],
["waiting", "Waiting", "等待条件"], ["failed", "Needs review", "需检查"],
["interrupted", "Interrupted", "已中断"], ["completed", "Completed", "已完成"],
]) {
const run = {todoId: todo.todoId, goalId: goal.goalId, status, title: todo.text, agentLabel: "Worker"};
const board = render(GoalTasksView, {goal, items: [{kind: "run", id: "execution", run}], userTodos: [], onSelect() {}});
const timeline = render(RunRow, {run, onSelect() {}});
const expected = locale === "en" ? en : zh;
assert.ok(board.includes(`personal-task-session-status">${expected}</span>`), `Task cards preserve ${status}`);
assert.ok(timeline.includes(expected), `Timeline and task cards agree for ${status}`);
}
}
} finally {
if (originalWindow === undefined) delete globalThis.window;
Expand Down
4 changes: 2 additions & 2 deletions apps/presentation/dashboard/src/data/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export const todoGroupSchema = z.object({
source_section: z.string().optional().nullable(),
total_count: z.number().optional().default(0),
open_count: z.number().optional(),
done_count: z.number().optional().default(0),
done_count: z.number().optional(),
advancement_done_count: z.number().optional(),
items: z.array(todoItemSchema).optional().default([]),
deferred_items: z.array(todoItemSchema).optional(),
Expand Down Expand Up @@ -258,7 +258,7 @@ export const goalChannelNotificationProjectionSchema = z.object({
export const projectAssetTodoSummarySchema = z.object({
source_section: z.string().optional().nullable(),
open: z.number().optional(),
done: z.number().optional().default(0),
done: z.number().optional(),
total: z.number().optional().default(0),
advancement_done_count: z.number().optional(),
next: z.string().optional().nullable(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,7 @@
import { Bot, ChevronRight, LoaderCircle } from "lucide-react";

import type { WorkspaceRun } from "../personal-workspace-model";
import { useWorkspaceI18n, type WorkspaceMessageKey } from "../i18n";

const runStatusKey: Record<WorkspaceRun["status"], WorkspaceMessageKey> = {
completed: "runs.completed",
failed: "runs.failed",
interrupted: "runs.interrupted",
queued: "runs.queued",
running: "runs.running",
waiting: "runs.waiting",
};
import { localizedSessionStatus, useWorkspaceI18n } from "../i18n";

export function RunRow({ onSelect, run, showGoal = true }: { onSelect: () => void; run: WorkspaceRun; showGoal?: boolean }) {
const { t } = useWorkspaceI18n();
Expand All @@ -20,7 +11,7 @@ export function RunRow({ onSelect, run, showGoal = true }: { onSelect: () => voi
<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>
<span className={`personal-row-status is-${run.status}`}>
{run.status === "running" ? <LoaderCircle className="personal-spin" size={14} /> : null}
{t(runStatusKey[run.status])}
{localizedSessionStatus(run.status, t)}
</span>
<ChevronRight size={17} />
</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
.goal-vital.is-danger .goal-vital-value { color: var(--pw-red); }
.goal-vital.is-recede .goal-vital-value { color: var(--pw-faint); font-weight: 500; }
.goal-vital-partial { color: var(--pw-amber); }
.goal-vital-qualifier { display: block; color: var(--pw-amber); font-size: var(--text-caption); line-height: var(--leading-caption); }
.goal-vital-swap { display: inline-block; min-width: 0; overflow: hidden; text-overflow: ellipsis; animation: goal-value-swap var(--motion-base) var(--ease-out); }
.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; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export function GoalVitals({ goal, items, onFocusRegion, userTodos }: {
t("vitals.workOpen", { count: work.open }),
work.classes.blocked ? t("vitals.workBlocked", { count: work.classes.blocked }) : null,
work.done === null ? t("vitals.workDoneUnknown") : null,
work.coverage === "unknown" ? t("vitals.workCoverageUnknown") : null,
].filter(Boolean).join(" · ");
const ribbonTotal = ribbonOrder.reduce((sum, name) => sum + work.classes[name], 0);

Expand All @@ -185,8 +186,11 @@ export function GoalVitals({ goal, items, onFocusRegion, userTodos }: {
<Vital label={t("vitals.work")} onClick={() => onFocusRegion("tasks")} valueKey={`${goal.goalId}:${work.done}:${work.total}:${work.open}`}
sub={work.coverage === "partial" && work.projectedOpen !== null
? <span className="goal-vital-partial">{t("vitals.workPartial", { listed: work.listedOpen, projected: work.projectedOpen })}</span> : workSub}
extra={ribbonTotal > 0 ? <span aria-hidden className="goal-work-ribbon">{ribbonOrder.map((name) => work.classes[name]
? <i className={`is-${name}`} key={name} style={{ flexGrow: work.classes[name] }} /> : null)}</span> : null}>
extra={<>
{work.done === null || work.coverage !== "complete" ? <span className="goal-vital-qualifier">{t("vitals.workTotalUnknown")}</span> : null}
{work.total !== null && ribbonTotal > 0 ? <span aria-hidden className="goal-work-ribbon">{ribbonOrder.map((name) => work.classes[name]
? <i className={`is-${name}`} key={name} style={{ flexGrow: work.classes[name] }} /> : null)}</span> : null}
</>}>
{work.total === null ? work.open : <span>{work.done}<small>/{work.total}</small></span>}
</Vital>
<Vital label={t("vitals.needsYou")} onClick={() => onFocusRegion("attention")} valueKey={`${goal.goalId}:${needs.availability}:${needs.availability === "ok" ? needs.blocking : ""}`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
WorkspaceModel,
WorkspaceTimelineItem,
} from "./personal-workspace-model";
import { localizedAttentionAge, useWorkspaceI18n } from "./i18n";
import { localizedAttentionAge, localizedSessionStatus, useWorkspaceI18n } from "./i18n";
import { CompletedTaskLane } from "./completed-task-lane";
import { MarkdownText } from "./markdown";
import { isGoalWorkTodo } from "../../../../../../loopx/control_plane/presentation/goal_workspace_digest.js";
Expand Down Expand Up @@ -256,7 +256,7 @@ export function GoalTasksView({
<small>
{todo.priority ? <span className={`personal-priority-badge is-${todo.priority.toLowerCase()}`}>{todo.priority}</span> : null}
{todo.status === "blocked" ? <span className="personal-priority-badge is-blocked">{t("tasks.blocked")}</span> : null}
{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}
{execution ? <span className="personal-task-session-status">{localizedSessionStatus(execution.status, t)}</span> : null}
{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}
<span>{enriched.ownerLabel}</span>
</small>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1389,6 +1389,8 @@ const en = {
"vitals.work": "Work",
"vitals.workOpen": "{count} open",
"vitals.workDoneUnknown": "done not reported",
"vitals.workCoverageUnknown": "listed work · total coverage unknown",
"vitals.workTotalUnknown": "Total unknown",
"vitals.workRunning": "{count} running",
"vitals.workBlocked": "{count} blocked",
"vitals.workPartial": "partial · {listed} of {projected} open listed",
Expand Down Expand Up @@ -2787,6 +2789,8 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"vitals.work": "工作",
"vitals.workOpen": "{count} 项未完成",
"vitals.workDoneUnknown": "未报告完成数",
"vitals.workCoverageUnknown": "当前所列工作 · 总体覆盖未知",
"vitals.workTotalUnknown": "总数未知",
"vitals.workRunning": "{count} 项执行中",
"vitals.workBlocked": "{count} 项受阻",
"vitals.workPartial": "部分 · 已列出 {projected} 项中的 {listed} 项",
Expand Down Expand Up @@ -2894,6 +2898,7 @@ export function localizedSessionStatus(status: string | undefined, t: WorkspaceT
ready: "runs.ready",
resume_failed: "runs.resumeFailed",
running: "runs.running",
interrupted: "runs.interrupted",
waiting: "runs.waiting",
};
return status ? (keyByStatus[status] ? t(keyByStatus[status]) : status) : t("runs.unknown");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -864,6 +864,10 @@ export function PersonalWorkspacePage({
const [lifecycleBusyGoalIds, setLifecycleBusyGoalIds] = useState<ReadonlySet<string>>(() => new Set());
const [quickCompletingTodoIds, setQuickCompletingTodoIds] = useState<ReadonlySet<string>>(() => new Set());
const [refreshState, setRefreshState] = useState<"idle" | "loading" | "done" | "error">("idle");
const refreshResetTimer = useRef<number | null>(null);
useEffect(() => () => {
if (refreshResetTimer.current !== null) window.clearTimeout(refreshResetTimer.current);
}, []);
const [historyRefreshRevision, setHistoryRefreshRevision] = useState(0);
const [sessionProposalIds, setSessionProposalIds] = useState<string[]>([]);
const [managerChannelProposalIds, setManagerChannelProposalIds] = useState<string[]>([]);
Expand Down Expand Up @@ -1928,6 +1932,10 @@ export function PersonalWorkspacePage({

async function refreshWorkspace() {
if (!callbacks.onRefresh || refreshState === "loading") return;
if (refreshResetTimer.current !== null) {
window.clearTimeout(refreshResetTimer.current);
refreshResetTimer.current = null;
}
setRefreshState("loading");
try {
await callbacks.onRefresh();
Expand All @@ -1936,7 +1944,10 @@ export function PersonalWorkspacePage({
} catch {
setRefreshState("error");
}
window.setTimeout(() => setRefreshState("idle"), 1800);
refreshResetTimer.current = window.setTimeout(() => {
refreshResetTimer.current = null;
setRefreshState("idle");
}, 1800);
}

const settingsPage = settingsOpen ? (
Expand Down Expand Up @@ -2236,7 +2247,7 @@ export function PersonalWorkspacePage({
<div className={composerCollapsed ? "personal-composer-folded" : undefined}>
{composerCollapsed && selectedGoal ? <>
<button className="personal-composer-pill" data-testid="overview-composer-pill" type="button"
onClick={() => { setOverviewComposerOpen(true); window.requestAnimationFrame(() => composerRef.current?.focus()); }}>
onClick={() => setOverviewComposerOpen(true)}>
<MessageCircleQuestion aria-hidden size={15} /><span>{t("composer.goalPlaceholder", { goal: selectedGoal.title })}</span>
</button>
</> : null}
Expand Down Expand Up @@ -2291,6 +2302,7 @@ export function PersonalWorkspacePage({
onPaste={handleComposerPaste}
placeholder={sending ? (locale === "zh-CN" ? "可以先写下后续问题…" : "Draft your next message…") : selectedGoal ? t("composer.goalPlaceholder", { goal: selectedGoal.title }) : workspaceTitle ? t("workspace.placeholder", { workspace: workspaceTitle }) : t("composer.managerPlaceholder")}
ref={composerRef}
autoFocus={overviewComposerOpen && selectedGoalTab === "overview"}
rows={1}
value={composer}
/>
Expand Down
14 changes: 7 additions & 7 deletions apps/presentation/dashboard/src/views/dashboard-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -388,10 +388,9 @@ function todosFromProjectAssetSummary(
if (summary?.items && (summary.items.length > 0 || (role === "user" && summary.open !== undefined))) {
return {
advancement_done_count: summary.advancement_done_count ?? fallback?.advancement_done_count,
done_count: summary.done ?? summary.items.filter((item) => item.done).length,
done_count: summary.done ?? fallback?.done_count,
items: summary.items,
open_count: summary.open ?? fallback?.open_count
?? (role === "user" ? undefined : summary.items.filter((item) => !item.done).length),
open_count: summary.open ?? fallback?.open_count,
total_count: summary.total ?? summary.items.length,
};
}
Expand Down Expand Up @@ -464,7 +463,7 @@ type PersonalGoalItem = {
agentId: string;
agentSentence: string;
agentTodos: WorkspaceAgentTodo[];
doneTodoCount: number;
doneTodoCount?: number;
openTodoCount?: number;
goalId: string;
latestActivity?: string;
Expand Down Expand Up @@ -799,7 +798,7 @@ function mergePersonalAgentTodos(
* recent-completed lane. Queue items may also include completed Todos.
*/
function personalAgentTodoFacts(row: GoalDirectoryRow): {
doneTodoCount: number;
doneTodoCount?: number;
nextTodoText: string | null;
recentCompleted: WorkspaceAgentTodo[];
} {
Expand All @@ -811,8 +810,9 @@ function personalAgentTodoFacts(row: GoalDirectoryRow): {
?? assetTodos?.done
?? queueTodos?.done_count
?? null;
const doneFromItems = items.filter((todo) => todo.done && todo.status !== "deferred").length;
const doneTodoCount = Math.max(doneFromCount ?? 0, doneFromItems);
const doneFromItems = items.filter((todo) => todo.done && todo.status !== "deferred" && todo.task_class !== "continuous_monitor").length;
// Items are bounded observations. They cannot supply a missing whole-Goal total.
const doneTodoCount = doneFromCount === null ? undefined : Math.max(doneFromCount, doneFromItems);
const seenTodoIds = new Set(
items
.map((todo) => todo.todo_id?.trim())
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions docs/guides/personal-workspace-user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ Goal 顶部直接提供 **概览、任务、对话、成果**,分别用于判
右上角设置直接打开既有能力配置;返回后保留工作区。切换 Goal 或数据源则重新建立页面上下文。

点击一次 **概览**,即可查看当前进展、需要处理的决定、执行记录和用量。
工作指标只有在来源提供完成数、且未完成列表覆盖已知时显示“完成 / 总数”。
缺失或有界数据会保留“总数未知”,显示当前所列未完成项而不绘制完成比例;实际报告的零仍是零。
任务卡片与时间线区分已安排、执行中、等待、需检查、已中断和已完成;排队不表示已开始执行。
决定与执行记录直接打开原有详情;「查看任务」「查看成果」前往对应页面。
低频仓库、连接和运行信息保留在「Goal 信息」中,不再充当查看进展的必经路径。

Expand Down Expand Up @@ -237,6 +240,10 @@ task filters, loaded history and scroll; a different Goal or source starts a new
view session. Settings opens the existing capability editor directly.

Overview brings progress, pending decisions, execution and usage into one page.
The Work vital shows done/total only with a reported completion count and known complete open-work coverage.
Missing or bounded totals show the listed open work and an explicit unknown-total notice, without a completion ribbon;
a reported zero remains zero. Task cards and the timeline distinguish scheduled, running, waiting, failed,
interrupted and completed observations. Scheduled work is not presented as executing.
Its delivery section reads the bounded current chain and acceptance observations
on entry or explicit refresh, with search, map/list layouts, source navigation
and Markdown export. Leaving Overview aborts pending reads. A changed workspace
Expand Down
Loading
Loading