diff --git a/apps/presentation/dashboard/smoke/attention-details-smoke.ts b/apps/presentation/dashboard/smoke/attention-details-smoke.ts index 070ecab774..6ac93d6ae4 100644 --- a/apps/presentation/dashboard/smoke/attention-details-smoke.ts +++ b/apps/presentation/dashboard/smoke/attention-details-smoke.ts @@ -39,6 +39,11 @@ for (const task_class of ["user_action", undefined, "unknown_future_class"]) { const action = { ...row, details: attentionDetails({ ...source, task_class: "user_action" }) }; const boundAction = attentionDetails(todoItemSchema.parse({ ...source, task_class: "user_action", bound_agent: "codex-delivery" })); assert(boundAction.boundAgent === "codex-delivery", "canonical User action actor survives schema and projection"); +const reviewSource = {...source, role: "user", task_class: "user_action", action_kind: "review_periodic_report", + capability_binding_ref: `periodic-report-review:r${"a".repeat(64)}`}; +assert(attentionDetails(todoItemSchema.parse(reviewSource)).reportReview, "native report edition survives the production schema"); +for (const changed of [{role: "agent"}, {task_class: "user_gate"}, {capability_binding_ref: "periodic-report-review:r../other"}]) + assert(!attentionDetails(todoItemSchema.parse({...reviewSource, ...changed})).reportReview, "prose or another role cannot select report review"); assert(!canHandleUserAction({ ...row, details: attentionDetails(source) }), "a User gate is decided, not marked done"); const { todo_id: _omitted, ...idless } = source; assert(!canHandleUserAction({ ...action, details: attentionDetails({ ...idless, task_class: "user_action" }) }), "missing todo_id cannot be written"); diff --git a/apps/presentation/dashboard/src/data/report-reviews.ts b/apps/presentation/dashboard/src/data/report-reviews.ts new file mode 100644 index 0000000000..b321a80435 --- /dev/null +++ b/apps/presentation/dashboard/src/data/report-reviews.ts @@ -0,0 +1,22 @@ +import {requestJson} from "./chat"; +import {z} from "zod"; + +const row = z.object({todo_id: z.string(), bundle_sha256: z.string().regex(/^[a-f0-9]{64}$/), + title: z.string(), status: z.string(), created_by: z.string().nullable(), bound_agent: z.string().nullable(), + created_at: z.string().nullable(), updated_at: z.string().nullable(), completed_at: z.string().nullable(), + superseded_by: z.string().nullable()}); +export type ReportReviewRow = z.infer; +const page = z.object({ok: z.literal(true), available: z.boolean().default(true), + reason_code: z.string().optional(), items: z.array(row), total: z.number().int().nonnegative(), next_cursor: z.string().nullable()}); +const read = z.object({ok: z.literal(true), goal_id: z.string(), review: row, + source_session_id: z.string().regex(/^[a-f0-9]{32}$/).nullable(), + todo: z.record(z.string(), z.unknown()), document: z.object({title: z.string(), generated_at: z.string()}).passthrough(), + generation_id: z.string(), artifacts: z.array(z.object({renderer_id: z.string(), content: z.string(), content_digest: z.string()}))}); +export type ReportReviewRead = z.infer; +export async function fetchReportReviews(goalId: string, cursor?: string) { + const query = new URLSearchParams({goal_id: goalId}); if (cursor) query.set("cursor", cursor); + return page.parse(await requestJson(`/api/chat/report-reviews?${query}`)); +} +export async function readReportReview(goalId: string, todoId: string) { + return read.parse(await requestJson(`/api/chat/report-reviews/${encodeURIComponent(todoId)}?${new URLSearchParams({goal_id: goalId})}`)); +} diff --git a/apps/presentation/dashboard/src/data/status.ts b/apps/presentation/dashboard/src/data/status.ts index 457e83f617..60422bca19 100644 --- a/apps/presentation/dashboard/src/data/status.ts +++ b/apps/presentation/dashboard/src/data/status.ts @@ -88,6 +88,7 @@ export const todoItemSchema = z.object({ watch_only: z.union([z.boolean(), z.string()]).optional().nullable(), task_domain: z.string().optional().nullable(), action_kind: z.string().optional().nullable(), + capability_binding_ref: z.string().optional().nullable(), // Canonical author provenance, distinct from the Agent authorized to reply. created_by: z.string().optional().nullable(), claimed_by: z.string().optional().nullable(), @@ -114,6 +115,17 @@ export const todoItemSchema = z.object({ { path: ["todo_id"], message: "Todo without a source index requires todo_id" }, ); +export const todoCapabilityActionsSchema = z.object({ + schema_version: z.literal("todo_capability_actions_v0"), + total_count: z.number().int().nonnegative(), + groups: z.array(z.object({ + action_kind: z.string().min(1), + count: z.number().int().nonnegative(), + latest_at: z.string().optional().nullable(), + items: z.array(todoItemSchema).max(3), + })).default([]), +}); + export const todoGroupSchema = z.object({ source_section: z.string().optional().nullable(), total_count: z.number().optional().default(0), @@ -123,6 +135,7 @@ export const todoGroupSchema = z.object({ items: z.array(todoItemSchema).optional().default([]), deferred_items: z.array(todoItemSchema).optional(), recent_completed_advancement_items: z.array(todoItemSchema).optional(), + capability_actions: todoCapabilityActionsSchema.optional(), }); export const todoIndexItemSchema = todoItemSchema.safeExtend({ @@ -267,6 +280,7 @@ export const projectAssetTodoSummarySchema = z.object({ next_index: z.number().optional().nullable(), items: z.array(todoItemSchema).optional().default([]), recent_completed_advancement_items: z.array(todoItemSchema).optional().default([]), + capability_actions: todoCapabilityActionsSchema.optional(), }); export const dependencyBlockerSchema = z.object({ diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attention-actions.tsx b/apps/presentation/dashboard/src/features/personal-workspace/attention-actions.tsx index 6b2dd06766..599817ceaa 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/attention-actions.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/attention-actions.tsx @@ -15,7 +15,8 @@ export function AttentionActions({ item, readOnly, callbacks, fallbackAgentId }: callbacks: PersonalWorkspaceCallbacks; fallbackAgentId: string; }) { - const { t } = useWorkspaceI18n(); + const { t, locale } = useWorkspaceI18n(); + const reportReview = item.details?.reportReview === true; const [preview, setPreview] = useState({ status: "idle" }); const scope = JSON.stringify([item.sourceId ?? "", item.goalId, item.todoId]); const pendingRequest = useRef<{scope: string; token: object | null}>({scope, token: null}); @@ -83,7 +84,7 @@ export function AttentionActions({ item, readOnly, callbacks, fallbackAgentId }: ?

{t("drawer.attentionPreviewFailed", { error: preview.message })}

: pending ?

{t("drawer.attentionPreparing")}

: null; const reply = callbacks.onReplyToAttention - ? + ? : null; const explain = (label: string) => callbacks.onExplainDecision ? @@ -110,9 +111,9 @@ export function AttentionActions({ item, readOnly, callbacks, fallbackAgentId }: } if (canHandleUserAction(item)) { return <> -

{t(item.details?.unblocksTodoId ? "drawer.userActionUnblocks" : "drawer.userActionReminder")}

+ {!reportReview ?

{t(item.details?.unblocksTodoId ? "drawer.userActionUnblocks" : "drawer.userActionReminder")}

: null}
- + {reply}
{feedback} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/attention-details.ts b/apps/presentation/dashboard/src/features/personal-workspace/attention-details.ts index a71ea84da3..146ba5e09f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/attention-details.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/attention-details.ts @@ -1,5 +1,6 @@ import type { WorkspaceAttention } from "./personal-workspace-model"; import { todoRequestContent } from "../../../../../../loopx/control_plane/presentation/todo_request_content.js"; +import {reportReviewDigest} from "../../../../../../loopx/control_plane/presentation/report_review_binding.js"; /** A display of existing Todo facts, never a gate or dependency evaluator. */ export type AttentionDetails = { @@ -18,6 +19,8 @@ export type AttentionDetails = { unblocksTodoId: string | null; decisionScope: { kind: string; granularity: string; scopeKey: string } | null; supersededBy: string | null; + reportReview?: boolean; + reportSessionId?: string; }; const text = (value: unknown): string | null => typeof value === "string" && value.trim() ? value.trim() : null; @@ -47,6 +50,7 @@ export function attentionDetails(todo: Record): AttentionDetail unblocksTodoId: text(todo.unblocks_todo_id), decisionScope: kind && granularity && scopeKey ? { kind, granularity, scopeKey } : null, supersededBy, + ...(reportReviewDigest(todo) ? {reportReview: true} : {}), }; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts index 3ceeed9f20..c344ded2c2 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/capability-localization.ts @@ -178,6 +178,7 @@ const fieldCopy: Record = { composition_mode: { label: "Composition policy", description: "Replan requires an exact experiment successor or typed result; it grants no execution authority.", options: {disabled: "Off", explicit_only: "Explicit candidates"} }, composition_scope_id: { label: "Research coverage scope", description: "An opaque scope id required by the explicit-only composition policy." }, profile_preset: { label: "Report profile", description: "Capability-owned report profile, such as weekly-progress." }, + review_required: { label: "Request review when ready", description: "For reports returned to this Goal's original conversation. Each edition stays on Home until you handle its review." }, wait_for_ci: { label: "Wait for CI", description: "Disable to use local validation without querying or waiting for CI. Merge authority is unchanged." }, review_order: { label: "Review direction", description: "Forward visits other authors first and oldest first. Reverse inverts the whole actionable queue." }, agent_orders: { label: "Agent review directions", description: "Use the Goal default or choose a direction for each registered Agent." }, @@ -214,6 +215,7 @@ const fieldCopy: Record = { composition_mode: { label: "组合策略", description: "重新规划需要精确的实验后继或类型化结果;它不授予执行权限。", options: {disabled: "关闭", explicit_only: "仅显式候选"} }, composition_scope_id: { label: "研究覆盖范围", description: "显式组合策略要求填写不含私有内容的范围标识。" }, profile_preset: { label: "报告 Profile", description: "由该能力管理的报告 profile,例如 weekly-progress。" }, + review_required: { label: "材料就绪后请求验收", description: "适用于回到本 Goal 原工作对话的报告。每个版本独立提醒,处理审阅后从首页移除。" }, wait_for_ci: { label: "等待 CI", description: "关闭后使用本地验证,不查询或等待 CI;不改变合并权限。" }, review_order: { label: "审阅方向", description: "正向先审其他作者,同层先审较早就绪的 PR;反向将整个可执行队列倒序。" }, agent_orders: { label: "Agent 审阅方向", description: "每个注册 Agent 可继承 Goal,或单独选择正向、反向。" }, diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-report-reviews.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-report-reviews.tsx new file mode 100644 index 0000000000..d98d0b0fad --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-report-reviews.tsx @@ -0,0 +1,176 @@ +import {useEffect, useMemo, useRef, useState} from "react"; +import {RefreshCw} from "lucide-react"; +import {fetchReportReviews, readReportReview, type ReportReviewRead, type ReportReviewRow} from "../../data/report-reviews"; +import type {PersonalWorkspaceCallbacks, WorkspaceAttention} from "./personal-workspace-model"; +import {attentionDetails} from "./attention-details"; +import {AttentionActions} from "./attention-actions"; +import {ContextHelp} from "./context-help"; +import {MarkdownText} from "./markdown"; +import {ResultCatalogReturn, ResultCatalogRow, useResultCatalogFocus} from "./result-catalog"; +import "./report-reviews.css"; + +/** Generated HTML gets no scripts, forms, same-origin access or host functions. + * Review controls stay in the trusted host; the exact Markdown remains readable. */ +function inertReportHtml(content: string, zh: boolean): string { + const document = new DOMParser().parseFromString(content, "text/html"); + // The built-in standalone renderer has script-driven search/copy/print. + // Its static embedded reading view keeps the content and navigation, without + // presenting controls that cannot run in the scriptless frame. + document.querySelectorAll("script, .tools, [data-report-search]").forEach(node => node.remove()); + for (const anchor of document.querySelectorAll("a[href]")) { + const href = anchor.getAttribute("href")!; + if (href.startsWith("#")) anchor.setAttribute("href", `about:srcdoc${href}`); + else { + anchor.removeAttribute("href"); + anchor.setAttribute("aria-disabled", "true"); + anchor.setAttribute("title", document.documentElement.lang.startsWith("zh") ? "请在文字版打开来源链接" : "Open source links in text view"); + } + } + // The host already supplies the exact title and edition. Keep every summary, + // period, highlight and source inside the reading view without a second hero. + document.querySelector(".masthead h1")?.remove(); + const metadata = document.querySelector(".supporting-body"); + // Counts and the reporting window remain available with the other report + // facts. The reading viewport starts with editorial summaries and content. + for (const node of document.querySelectorAll(".masthead .eyebrow, .masthead .period, .report-facts.fallback")) { + if (metadata) metadata.append(node); + } + const mast = document.querySelector(".masthead"); + if (mast && !mast.textContent?.trim()) mast.remove(); + const rail = document.querySelector(".rail"); + if (rail) { + const index = document.createElement("details"); + index.className = "embedded-index"; + const summary = document.createElement("summary"); + summary.textContent = zh ? "目录" : "Contents"; + index.append(summary); + const nav = rail.querySelector("nav"); + if (nav) index.append(nav); + rail.replaceWith(index); + } + const embedded = document.createElement("style"); + embedded.textContent = ".shell{width:100%;max-width:none;margin:0;padding:24px;box-sizing:border-box}.masthead{padding:0 0 16px;border-top:0}.mast-grid{gap:16px}.layout{grid-template-columns:minmax(0,1fr);margin-top:0;gap:16px}.masthead+.layout{margin-top:16px}.embedded-index summary{cursor:pointer;min-height:32px}.section-heading h2{font-size:24px}.report-row{padding:16px 0}.report-facts.fallback{grid-template-columns:repeat(3,minmax(0,1fr))}.report-facts.fallback .fact{min-width:0}a[aria-disabled=true]{color:inherit;text-decoration:none;cursor:help}@media(max-width:640px){.shell{padding:16px}}"; + document.head.append(embedded); + const policy = document.createElement("meta"); + policy.httpEquiv = "Content-Security-Policy"; + policy.content = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; form-action 'none'; base-uri 'none'"; + document.head.prepend(policy); + return "" + document.documentElement.outerHTML; +} + +export function GoalReportReviews({goalId, sourceId, zh, focusTodoId, refreshKey, callbacks, readOnly = false, onSelectionChange}: { + goalId: string; sourceId?: string; zh: boolean; focusTodoId?: string; refreshKey: string; + callbacks: PersonalWorkspaceCallbacks; readOnly?: boolean; + onSelectionChange?: (todoId?: string) => void; +}) { + const [rows, setRows] = useState([]); + const [selected, setSelected] = useState(null); + const [reading, setReading] = useState(focusTodoId ?? null); + const [cursor, setCursor] = useState(null); + const [busy, setBusy] = useState(false); + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState(""); + const [textOnly, setTextOnly] = useState(false); + const generation = useRef(0); + const navigation = useRef(""); + const selectedId = useRef(focusTodoId ?? null); + const focus = useResultCatalogFocus(); + const storageKey = JSON.stringify(["loopx-report-reading", sourceId ?? location.origin, goalId]); + function rememberSelection(todoId?: string) { + selectedId.current = todoId ?? null; + try { + if (todoId) sessionStorage.setItem(storageKey, todoId); + else sessionStorage.removeItem(storageKey); + } catch { /* Reading works without storage. */ } + onSelectionChange?.(todoId); + } + useEffect(() => { + const destination = JSON.stringify([storageKey, focusTodoId]); + if (navigation.current !== destination) { + navigation.current = destination; + let saved: string | null = null; + try {saved = sessionStorage.getItem(storageKey);} catch { /* Reading works without storage. */ } + selectedId.current = focusTodoId ?? saved; + setReading(selectedId.current); setRows([]); setLoaded(false); setTextOnly(false); + } + void load(); + return () => {generation.current++; focus.cancel();}; + }, [goalId, sourceId, focusTodoId, refreshKey]); + + async function read(todoId: string, current: number) { + const value = await readReportReview(goalId, todoId); + if (current !== generation.current) return; + if (value.goal_id !== goalId || value.review.todo_id !== todoId || value.todo.todo_id !== todoId) + throw Error(zh ? "材料来源已变化" : "Material source changed"); + setSelected(value); + rememberSelection(todoId); + } + async function load(next?: string) { + const current = ++generation.current; + setBusy(true); setError(""); setSelected(null); + try { + const page = await fetchReportReviews(goalId, next); + if (current !== generation.current) return; + if (!page.available) { + setRows([]); setCursor(null); setLoaded(true); + if (selectedId.current) setError(zh ? "请先按 Goal 的提示完成权威存储迁移,再刷新原材料。" : "Complete the Goal's authority migration, then refresh the original material."); + return; + } + setRows(previous => next ? [...previous, ...page.items] : page.items); + setCursor(page.next_cursor); setLoaded(true); + if (selectedId.current) await read(selectedId.current, current); + } catch { + if (current === generation.current) setError(zh ? "材料或审阅状态暂不可读,请重试。" : "Material or review state is unavailable. Retry the read."); + } finally {if (current === generation.current) setBusy(false);} + } + async function select(row: ReportReviewRow) { + const current = ++generation.current; + setReading(row.todo_id); setSelected(null); setTextOnly(false); + rememberSelection(row.todo_id); + setBusy(true); setError(""); focus.reader(); + try {await read(row.todo_id, current);} + catch {if (current === generation.current) setError(zh ? "原版本已不可读取,请重试。" : "The original edition cannot be read. Retry.");} + finally {if (current === generation.current) setBusy(false);} + } + const label = (row: ReportReviewRow) => row.superseded_by ? (zh ? "已有修订" : "Superseded") + : row.status === "done" ? (zh ? "审阅已处理" : "Review handled") + : row.status === "deferred" ? (zh ? "稍后审阅" : "Review deferred") + : ["open", "blocked"].includes(row.status) ? (zh ? "待验收与反馈" : "Awaiting review") : row.status; + const markdown = selected?.artifacts.find(item => item.renderer_id === "markdown_v0"); + const html = selected?.artifacts.find(item => item.renderer_id === "html_artifact_v0"); + const htmlView = useMemo(() => html ? inertReportHtml(html.content, zh) : "", [html?.content, zh]); + function returnToCatalog() { + generation.current++; setReading(null); setSelected(null); setBusy(false); setError(""); + rememberSelection(); + if (reading) focus.row(reading); + } + const attention: WorkspaceAttention | null = selected ? {goalId, sourceId, todoId: selected.review.todo_id, + text: selected.review.title, blocking: false, decisionSource: "todo", details: {...attentionDetails(selected.todo), + ...(selected.source_session_id ? {reportSessionId: selected.source_session_id} : {})}} : null; + if (loaded && !rows.length && !reading && !error) return null; + return
+
{reading ? :

{zh ? "审阅材料" : "Review materials"}

} +
+

{zh ? "阅读不会完成验收。每份材料的审阅分别记录;处理材料不等于整个 Goal 验收通过。反馈回到负责 Agent 的原工作对话。页面版保留静态目录;外部来源可从文字版打开。" : "Reading does not accept a result. Each edition has its own review; handling it does not accept the entire Goal. Feedback returns to its responsible Agent's work conversation. Page view keeps the static index; open external sources from text view."}

+
+ {busy ?

{zh ? "正在读取材料…" : "Reading material…"}

: null} + {error ?

{error}

: null} + + {reading ?
+ {selected ? <>

{selected.document.title}

+
{label(selected.review)} + {html && markdown ? : null}
+ {attention ?
: null} + {html && !textOnly ?