diff --git a/apps/presentation/dashboard/smoke/goal-workspace-craft-browser-smoke.mjs b/apps/presentation/dashboard/smoke/goal-workspace-craft-browser-smoke.mjs index 8e758bf090..ca199f23b3 100644 --- a/apps/presentation/dashboard/smoke/goal-workspace-craft-browser-smoke.mjs +++ b/apps/presentation/dashboard/smoke/goal-workspace-craft-browser-smoke.mjs @@ -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'); diff --git a/apps/presentation/dashboard/smoke/status-projection-contract-smoke.ts b/apps/presentation/dashboard/smoke/status-projection-contract-smoke.ts index 137013ac63..588dd7723d 100644 --- a/apps/presentation/dashboard/smoke/status-projection-contract-smoke.ts +++ b/apps/presentation/dashboard/smoke/status-projection-contract-smoke.ts @@ -3,6 +3,8 @@ import { z } from "zod"; import { parseStatusPayload, todoItemSchema, + todoGroupSchema, + projectAssetTodoSummarySchema, todoIndexItemSchema, periodicReportIndexItemSchema, periodicReportIndexResponseSchema, @@ -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)[field], undefined, "Missing completion total stays unknown"); + const measured = schema.parse({ items: [], [field]: 0 }); + equal((measured as Record)[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" }; diff --git a/apps/presentation/dashboard/smoke/todo-ownership-smoke.mjs b/apps/presentation/dashboard/smoke/todo-ownership-smoke.mjs index 8fb584479f..609639438d 100644 --- a/apps/presentation/dashboard/smoke/todo-ownership-smoke.mjs +++ b/apps/presentation/dashboard/smoke/todo-ownership-smoke.mjs @@ -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"; @@ -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. @@ -92,6 +94,52 @@ try { assert.ok(drawer.includes(`
${owner}
${expected}
`), "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('["lineage","open"]'),"Lineage is not a completion prerequisite"); + assert.ok(relations.includes('["monitor-wait","open"]'),"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}`), `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}`), "A historical run cannot make unknown or stale work live"); + } + } } } finally { if (originalWindow === undefined) delete globalThis.window; diff --git a/apps/presentation/dashboard/src/data/status.ts b/apps/presentation/dashboard/src/data/status.ts index 66fb9cc558..4d31de12b3 100644 --- a/apps/presentation/dashboard/src/data/status.ts +++ b/apps/presentation/dashboard/src/data/status.ts @@ -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(), @@ -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(), diff --git a/apps/presentation/dashboard/src/features/personal-workspace/cards/run-row.tsx b/apps/presentation/dashboard/src/features/personal-workspace/cards/run-row.tsx index 6c1a4b591c..6b5edad89d 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/cards/run-row.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/cards/run-row.tsx @@ -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 = { - 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(); @@ -20,7 +11,7 @@ export function RunRow({ onSelect, run, showGoal = true }: { onSelect: () => voi {showGoal ? `${run.goalTitle} · ` : ""}{run.agentLabel}{run.title}{run.latestActivity !== run.title ? {run.latestActivity} : null} {run.status === "running" ? : null} - {t(runStatusKey[run.status])} + {localizedSessionStatus(run.status, t)} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx index 01bbbdbe1f..1e50839992 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx @@ -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"; @@ -16,6 +16,8 @@ export function ChannelHeader({ mobileNavigationOpen, onOpenGoalCapabilities, onOpenGoalDetails, + onOpenAllWork, + allWorkOpen, onOpenManagerChat, onOpenManagerSettings, onOpenWorkspace, @@ -45,6 +47,8 @@ export function ChannelHeader({ mobileNavigationOpen?: boolean; onOpenGoalCapabilities?: () => void; onOpenGoalDetails?: () => void; + onOpenAllWork?: () => void; + allWorkOpen?: boolean; onOpenManagerChat?: () => void; onOpenManagerSettings?: () => void; onOpenWorkspace?: () => void; @@ -243,6 +247,7 @@ export function ChannelHeader({ )}
+ {selectedGoal && onOpenAllWork ? : null} {selectedGoal && !selectedGoal.loadState && onRequestGoalLifecycle ? ( ) : null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/completed-task-lane.tsx b/apps/presentation/dashboard/src/features/personal-workspace/completed-task-lane.tsx index ff92756e99..ab030b9e16 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/completed-task-lane.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/completed-task-lane.tsx @@ -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(); @@ -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(agentId === "all" ? goal.doneTodoCount ?? null : null); const [cursor, setCursor] = useState(undefined); const [busy, setBusy] = useState(false); const [error, setError] = useState(false); @@ -36,6 +37,7 @@ export function CompletedTaskLane({ goal, agentId, seed, enabled, refreshRevisio const [focused, setFocused] = useState(null); const scroll = useRef(null); const request = useRef(null); + const [more, setMore] = useState(false); const [retry, setRetry] = useState(0); useEffect(() => { const element = scroll.current; @@ -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); @@ -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); @@ -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
-
{listView ? :