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
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,13 @@ try {
const { page } = session;
try {
await openShowcaseGoal(page);
await page.getByRole("region", {name:"当前工作",exact:true}).getByRole("button",{name:"关系图",exact:true}).click();
const heading = page.locator('.personal-channel-title h1');
assert.equal(await heading.innerText(), longTitle, 'The original Goal title remains complete');
assert.doesNotMatch(await page.locator('.goal-masthead-breadcrumb').evaluate(element => [...element.children].filter(child => child.getBoundingClientRect().width > 2).map(child => child.textContent).join(' ')), /preserve all consumer/, 'Visible breadcrumb does not repeat the long request');
assert.equal(await page.locator('.goal-masthead-breadcrumb [aria-current]').textContent(), longTitle, 'Assistive readers retain the complete current-page identity');
assert.doesNotMatch(await page.getByTestId('overview-composer-pill').innerText(), /ledger|consumer/, 'The input invitation stays concise');
await page.locator('.goal-work-lenses').getByRole('button', {name: /^(关系图|Map)$/}).click();
const map = page.locator('.work-map');
await map.scrollIntoViewIfNeeded();
const scroll = page.locator('.personal-channel-scroll');
Expand Down
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
48 changes: 48 additions & 0 deletions apps/presentation/dashboard/smoke/todo-ownership-smoke.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {useGoalWorkspaceDigest} from "../src/features/personal-workspace/goal-masthead.tsx";
import { GoalTasksView } from "../src/features/personal-workspace/goal-tasks-view.tsx";
import { CompletedTaskLane } from "../src/features/personal-workspace/completed-task-lane.tsx";
import { todoItemSchema } from "../src/data/status.ts";
Expand All @@ -9,6 +10,7 @@ import { ContextDrawer } from "../src/features/personal-workspace/context-drawer
import { WorkspaceI18nProvider, useWorkspaceI18n } from "../src/features/personal-workspace/i18n.tsx";
import { ChatApiError } from "../src/data/chat.ts";
import { todoCompletionPreviewFeedback } from "../src/features/personal-workspace/todo-completion-feedback.ts";
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 @@ -92,6 +94,52 @@ 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");
}
for (const detailMode of ["request_only", "missing_current"]) {
const drawer = render(ContextDrawer, {agents:[], callbacks:{}, onClose(){}, selection:{kind:"todo",item:{todoId:"missing",sourceTodoId:"missing",goalId:"test-goal",goalTitle:"Test Goal",text:"Past request",done:false,detailMode}}});
assert.ok(!drawer.includes("personal-task-inspector-actions"), "Unavailable inspection cannot retain effect controls");
assert.ok(drawer.includes(detailMode === "missing_current"
? (locale === "en" ? "Current task state is unavailable." : "当前任务状态不可用。")
: (locale === "en" ? "outside the current summary" : "不在当前摘要中")));
}
// Exact typed relations, rather than graph adjacency, classify held work.
function DigestProbe({map}) {
const digest = useGoalWorkspaceDigest({goalId:"relations",agentTodos:[
{todoId:"held",text:"Held work",done:false,status:"blocked",claimedBy:"worker"},
{todoId:"lineage",text:"Historical lineage",done:false,status:"open"},
{todoId:"monitor-wait",text:"Monitor condition",done:false,status:"open"},
]}, [{todoId:"decision",goalId:"relations",blocking:true,text:"Owner decision"}], [], map);
return JSON.stringify(digest.work.rows.map(row=>[row.todoId,row.displayClass]));
}
const node = (id,kind,state="open") => ({node_id:id,kind,state,depth:0,refs:{todo_ids:[id]}});
const map = {nodes:[node("decision","gate"),node("held","deliverable"),node("lineage","deliverable"),node("watch","monitor"),node("monitor-wait","deliverable")],
edges:[{from_node_id:"held",to_node_id:"decision",relation:"depends_on",enforcement:"typed_lifecycle"},
{from_node_id:"lineage",to_node_id:"held",relation:"continues",enforcement:"lineage_only"},
{from_node_id:"monitor-wait",to_node_id:"watch",relation:"depends_on",enforcement:"typed_condition"}]};
const relations = render(DigestProbe,{map});
assert.ok(relations.includes("needs_you"),"The actual adapter adopts the recorded decision dependency");
assert.ok(relations.includes('[&quot;lineage&quot;,&quot;open&quot;]'),"Lineage is not a completion prerequisite");
assert.ok(relations.includes('[&quot;monitor-wait&quot;,&quot;open&quot;]'),"Monitor generation change does not imply completion waiting");
const completedGate = render(DigestProbe,{map:{...map,nodes:map.nodes.map(node=>node.node_id==="decision"?{...node,state:"done"}:node)}});
assert.ok(!completedGate.includes("needs_you"),"A done map gate cannot keep a task held by that decision");
const todo = {todoId: "run-task", text: "Inspect execution", done: false, status: "open"};
const goal = {goalId: "run-goal", title: "Execution facts", agentTodos: [todo],
execution: {kind: "running", hostClaimed: false, quiet: false, lastActivityAt: new Date().toISOString(), hostSurfaces: [], claimedAt: null}};
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}`);
if (status === "running") for (const execution of [{kind: "unknown"}, {...goal.execution, quiet: true}, {...goal.execution, hostClaimed: true}]) {
const unknown = render(GoalTasksView, {goal: {...goal, execution}, items: [{kind: "run", id: "execution", run}], userTodos: [], onSelect() {}});
assert.ok(!unknown.includes(`personal-task-session-status">${expected}</span>`), "A historical run cannot make unknown or stale work live");
}
}
}
} 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
@@ -1,4 +1,4 @@
import { Bot, Eye, FolderOpen, Menu, RefreshCw, SlidersHorizontal } from "lucide-react";
import { List, Bot, Eye, FolderOpen, Menu, RefreshCw, SlidersHorizontal } from "lucide-react";
import { GoalLifecycleControl, GoalMastheadIdentity } from "./goal-masthead";

import { useWorkspaceI18n } from "./i18n";
Expand All @@ -16,6 +16,8 @@ export function ChannelHeader({
mobileNavigationOpen,
onOpenGoalCapabilities,
onOpenGoalDetails,
onOpenAllWork,
allWorkOpen,
onOpenManagerChat,
onOpenManagerSettings,
onOpenWorkspace,
Expand Down Expand Up @@ -45,6 +47,8 @@ export function ChannelHeader({
mobileNavigationOpen?: boolean;
onOpenGoalCapabilities?: () => void;
onOpenGoalDetails?: () => void;
onOpenAllWork?: () => void;
allWorkOpen?: boolean;
onOpenManagerChat?: () => void;
onOpenManagerSettings?: () => void;
onOpenWorkspace?: () => void;
Expand Down Expand Up @@ -243,6 +247,7 @@ export function ChannelHeader({
</nav>
)}
<div className="personal-channel-actions">
{selectedGoal && onOpenAllWork ? <button type="button" className="personal-icon-button goal-all-work-trigger" aria-label={locale === "zh-CN" ? "打开全部工作" : "Open all work"} aria-expanded={allWorkOpen} aria-haspopup="dialog" onClick={onOpenAllWork}><List size={17} aria-hidden/></button> : null}
{selectedGoal && !selectedGoal.loadState && onRequestGoalLifecycle ? (
<GoalLifecycleControl busy={goalLifecycleBusy ?? false} goal={selectedGoal} onRequest={onRequestGoalLifecycle} resumeReviewed={resumeReviewed ?? true} />
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ const pageSchema = z.object({
});

/** Fixed-height previews keep layout/DOM cost bounded; the drawer retains full text. */
export function CompletedTaskLane({ goal, agentId, seed, enabled, refreshRevision = 0, listView = false, onSelect }: {
export function CompletedTaskLane({ goal, agentId, seed, enabled, refreshRevision = 0, listView = false, onSelect, selectedTodoId = null, query = "" }: {
goal: WorkspaceGoal; agentId: string; seed: WorkspaceAgentTodo[]; enabled: boolean;
selectedTodoId?: string | null; query?: string;
refreshRevision?: number; listView?: boolean; onSelect: (selection: WorkspaceDrawerSelection) => void;
}) {
const { t } = useWorkspaceI18n();
Expand All @@ -27,7 +28,7 @@ export function CompletedTaskLane({ goal, agentId, seed, enabled, refreshRevisio
const visible = !listView || expanded;
const rowHeight = listView ? 96 : 148;
const [rows, setRows] = useState(seed);
const [total, setTotal] = useState(agentId === "all" ? goal.doneTodoCount ?? seed.length : seed.length);
const [total, setTotal] = useState<number | null>(agentId === "all" ? goal.doneTodoCount ?? null : null);
const [cursor, setCursor] = useState<string | null | undefined>(undefined);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);
Expand All @@ -36,6 +37,7 @@ export function CompletedTaskLane({ goal, agentId, seed, enabled, refreshRevisio
const [focused, setFocused] = useState<number | null>(null);
const scroll = useRef<HTMLDivElement>(null);
const request = useRef<AbortController | null>(null);
const [more, setMore] = useState(false);
const [retry, setRetry] = useState(0);
useEffect(() => {
const element = scroll.current;
Expand All @@ -51,8 +53,9 @@ export function CompletedTaskLane({ goal, agentId, seed, enabled, refreshRevisio
request.current?.abort();
request.current = null;
setRows(seed);
setTotal(agentId === "all" ? goal.doneTodoCount ?? seed.length : seed.length);
setTotal(agentId === "all" ? goal.doneTodoCount ?? null : null);
setCursor(undefined);
setMore(false);
setBusy(false);
setError(false);
setExpired(false);
Expand All @@ -63,10 +66,11 @@ export function CompletedTaskLane({ goal, agentId, seed, enabled, refreshRevisio
useEffect(() => { resetSnapshot(); }, [refreshRevision]);
const needsPage = cursor === undefined || viewport.top + viewport.height >= rows.length * rowHeight - rowHeight * 2;
useEffect(() => {
if (!enabled || !visible || !needsPage || cursor === null || error || request.current) return;
if (!enabled || !visible || (!needsPage && !more) || cursor === null || error || request.current) return;
const controller = new AbortController();
request.current = controller;
setBusy(true);
setMore(false);
const query = new URLSearchParams({ goal_id: goal.goalId });
if (agentId !== "all") query.set("agent_id", agentId);
if (cursor) query.set("cursor", cursor);
Expand All @@ -83,31 +87,32 @@ export function CompletedTaskLane({ goal, agentId, seed, enabled, refreshRevisio
}).catch(() => { if (!controller.signal.aborted) setError(true); }).finally(() => {
if (!controller.signal.aborted) { request.current = null; setBusy(false); }
});
}, [enabled, visible, needsPage, cursor, error, retry, goal.goalId, agentId, busy]);
}, [enabled, visible, needsPage, cursor, error, retry, goal.goalId, agentId, busy, more]);
useEffect(() => {
if (scroll.current) scroll.current.scrollTop = 0;
setViewport({ top: 0, height: scroll.current?.clientHeight ?? 600 });
setFocused(null);
}, [listView]);
}, [listView, query]);
const visibleRows = rows.filter(todo => todo.text.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
const first = Math.max(0, Math.floor(viewport.top / rowHeight) - 3);
const last = Math.min(rows.length, Math.ceil((viewport.top + viewport.height) / rowHeight) + 3);
const last = Math.min(visibleRows.length, Math.ceil((viewport.top + viewport.height) / rowHeight) + 3);
const indices = Array.from({ length: Math.max(0, last - first) }, (_, index) => first + index);
if (focused !== null && focused < rows.length && !indices.includes(focused)) indices.push(focused);
if (focused !== null && focused < visibleRows.length && !indices.includes(focused)) indices.push(focused);
return <section className={`personal-object-list personal-task-lane${listView ? " personal-completed-list" : ""}`} data-testid="completed-task-lane">
<header>{listView ? <button type="button" aria-controls={historyId} aria-expanded={expanded} onClick={() => setExpanded(value => !value)}><span aria-hidden="true">{expanded ? "▾" : "▸"}</span> {t("tasks.completed")}</button> : <strong><i aria-hidden="true" className="personal-kanban-dot tone-done" />{t("tasks.completed")}</strong>}<span>{total}</span></header>
<header>{listView ? <button type="button" aria-controls={historyId} aria-expanded={expanded} onClick={() => setExpanded(value => !value)}><span aria-hidden="true">{expanded ? "▾" : "▸"}</span> {t("tasks.completed")}</button> : <strong><i aria-hidden="true" className="personal-kanban-dot tone-done" />{t("tasks.completed")}</strong>}<span>{total ?? "—"}</span></header>
<div id={historyId} hidden={!visible} 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 })}>
<div className="personal-completed-window" style={{ height: rows.length * rowHeight }}>
<div className="personal-completed-window" style={{ height: visibleRows.length * rowHeight }}>
{indices.map((index) => {
const todo = rows[index]!;
const todo = visibleRows[index]!;
return <div className="personal-task-card personal-completed-row" key={todo.todoId} style={{ top: index * rowHeight, height: rowHeight }}>
<button type="button" onFocus={() => setFocused(index)} onBlur={() => setFocused(null)} onClick={() => onSelect({ kind: "todo", item: { ...todo, goalId: goal.goalId, goalTitle: goal.title, ownerLabel: todo.claimedBy } })}>
<button type="button" aria-pressed={selectedTodoId === todo.todoId} onFocus={() => setFocused(index)} onBlur={() => setFocused(null)} onClick={() => onSelect({ kind: "todo", item: { ...todo, goalId: goal.goalId, goalTitle: goal.title, ownerLabel: todo.claimedBy } })}>
<span className="is-done">✓</span><strong>{compactWorkspaceText(todo.text, 112)}</strong><small>{todo.claimedBy ?? t("drawer.notAssigned")}</small>
</button>
</div>;
})}
</div>
<div className="personal-completed-footer" role="status">
{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>}
{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={() => setMore(true)}>{t("tasks.historyMore")}</button>}
</div>
</div>
</section>;
Expand Down
Loading
Loading