diff --git a/apps/presentation/dashboard/package.json b/apps/presentation/dashboard/package.json index 1b9f9f649..4fe86c367 100644 --- a/apps/presentation/dashboard/package.json +++ b/apps/presentation/dashboard/package.json @@ -49,7 +49,9 @@ "smoke:usage-progress": "rm -rf /tmp/loopx-usage-progress-smoke && tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --resolveJsonModule --esModuleInterop --jsx react-jsx --skipLibCheck --strict --outDir /tmp/loopx-usage-progress-smoke smoke/usage-progress-smoke.ts src/features/personal-workspace/personal-workspace-model.ts && node /tmp/loopx-usage-progress-smoke/apps/presentation/dashboard/smoke/usage-progress-smoke.js", "smoke:action-review-plan": "rm -rf .local/action-review-plan-smoke && tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --skipLibCheck --strict --rootDir ../../.. --outDir .local/action-review-plan-smoke smoke/action-review-plan-smoke.ts ../../../loopx/control_plane/presentation/action_review_plan.ts src/vite-env.d.ts && node .local/action-review-plan-smoke/apps/presentation/dashboard/smoke/action-review-plan-smoke.js", "smoke:attention-details": "tsc --ignoreConfig --target ES2022 --module CommonJS --moduleResolution Node --ignoreDeprecations 6.0 --resolveJsonModule --esModuleInterop --skipLibCheck --strict --outDir /tmp/loopx-attention-details-smoke smoke/attention-details-smoke.ts && NODE_PATH=\"$PWD/node_modules\" node /tmp/loopx-attention-details-smoke/apps/presentation/dashboard/smoke/attention-details-smoke.js", - "smoke:delivery-review": "tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck --strict --rootDir src --outDir node_modules/.cache/delivery-review src/data/delivery-review.ts src/data/goal-acceptance-observation.ts src/features/personal-workspace/delivery-review-copy.ts && node smoke/delivery-review-smoke.mjs" + "smoke:delivery-review": "tsc --ignoreConfig --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck --strict --rootDir src --outDir node_modules/.cache/delivery-review src/data/delivery-review.ts src/data/goal-acceptance-observation.ts src/features/personal-workspace/delivery-review-copy.ts && node smoke/delivery-review-smoke.mjs", + "smoke:team-artifact-comparison": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --skipLibCheck --strict --rootDir src --outDir node_modules/.cache/team-comparison src/features/personal-workspace/team-artifact-comparison.ts src/vite-env.d.ts && node smoke/team-artifact-comparison-smoke.mjs", + "smoke:team-report": "tsc --ignoreConfig --target ES2022 --module ES2022 --moduleResolution Bundler --jsx react-jsx --skipLibCheck --strict --rootDir src --outDir node_modules/.cache/team-report src/features/personal-workspace/team-artifact-content.tsx src/vite-env.d.ts && node smoke/team-report-smoke.mjs" }, "dependencies": { "@fontsource-variable/geist": "^5.3.0", diff --git a/apps/presentation/dashboard/smoke/team-artifact-comparison-smoke.mjs b/apps/presentation/dashboard/smoke/team-artifact-comparison-smoke.mjs new file mode 100644 index 000000000..5e177dce9 --- /dev/null +++ b/apps/presentation/dashboard/smoke/team-artifact-comparison-smoke.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import {comparisonSource, changedRange, preferredComparisonIndex} from "../node_modules/.cache/team-comparison/features/personal-workspace/team-artifact-comparison.js"; + +const link = {operation_id: "original", ref: "report.txt", sha256: "a".repeat(64), + input_ref: "input.txt", relation: "revises", state: "current"}; +const source = {operation_id: "original", request_id: "request", agent_id: "author", todo_id: "work", + status: "accepted", worker_active: false, recovery_required: false, + artifacts: [{ref: link.ref, sha256: link.sha256, text: "unchanged\nold\nend"}]}; +assert.equal(comparisonSource(link, source)?.text, "unchanged\nold\nend"); +for (const patch of [{operation_id: "other"}, {status: "rejected"}, {recovery_required: true}, {error: "unavailable"}, + {artifacts: [{ref: link.ref, sha256: "b".repeat(64), text: "new"}]}, + {artifacts: [{ref: "other.txt", sha256: link.sha256, text: "new"}]}, + {artifacts: [...source.artifacts, ...source.artifacts]}]) { + assert.equal(comparisonSource(link, {...source, ...patch}), null); +} +assert.equal(comparisonSource({...link, state: "unavailable"}, source), null); +assert.deepEqual(changedRange("unchanged\nold\nend", "unchanged\nnew\nend"), { + left: ["unchanged", "old", "end"], right: ["unchanged", "new", "end"], start: 1, leftEnd: 2, rightEnd: 2, +}); +const equal = changedRange("one\ntwo", "one\ntwo"); +assert.equal(equal.start, equal.leftEnd); +assert.equal(equal.start, equal.rightEnd); +const inserted = changedRange("one\nend", "one\nadded\nend"); +assert.equal(inserted.leftEnd, 1); assert.equal(inserted.rightEnd, 2); +const removed = changedRange("one\nremoved\nend", "one\nend"); +assert.equal(removed.leftEnd, 2); assert.equal(removed.rightEnd, 1); +const outputs = [{ref: "output.json"}, {ref: "review.md"}, {ref: "report.md"}]; +assert.equal(preferredComparisonIndex("report.md", outputs), 2); +assert.equal(preferredComparisonIndex("analysis.MD", outputs), 1); +assert.equal(preferredComparisonIndex("source.json", outputs), 0); +assert.equal(preferredComparisonIndex("no-extension", outputs), 0); +assert.equal(preferredComparisonIndex("source.txt", outputs), 0); +console.log("team artifact comparison: exact-version rejection and changed-range cases passed"); diff --git a/apps/presentation/dashboard/smoke/team-report-smoke.mjs b/apps/presentation/dashboard/smoke/team-report-smoke.mjs new file mode 100644 index 000000000..ae3e9c236 --- /dev/null +++ b/apps/presentation/dashboard/smoke/team-report-smoke.mjs @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import {createElement} from "react"; +import {renderToStaticMarkup} from "react-dom/server"; +import {MarkdownText} from "../node_modules/.cache/team-report/features/personal-workspace/markdown.js"; +import {TeamArtifactContent} from "../node_modules/.cache/team-report/features/personal-workspace/team-artifact-content.js"; + +const text = '# Research\n\n| Measure | Value |\n|---|---:|\n| **Cash** | 75 |\n| Escaped \\| label | 25 |\n\n[Source](https://example.org/report)\n\n[Unsafe](javascript:alert(1))\n![Tracking](https://example.org/pixel)'; +const artifact = {ref: "report.md", sha256: "a".repeat(64), text}; +const html = renderToStaticMarkup(createElement(TeamArtifactContent, {artifact, label: "Report"})); +assert.match(html, //); assert.match(html, /scope="col"/); assert.match(html, /Cash<\/strong>/); +assert.match(html, /Escaped \| label/); assert.match(html, /href="https:\/\/example.org\/report"/); +assert.doesNotMatch(html, /|/, "Existing chat rendering stays unchanged"); +const malformed = renderToStaticMarkup(createElement(MarkdownText, {text: "| One | Two |\n|---|\n| kept | intact |", report: true})); +assert.doesNotMatch(malformed, /
/); assert.match(malformed, /kept/); +const json = renderToStaticMarkup(createElement(TeamArtifactContent, {artifact: {...artifact, ref: "report.json"}, label: "JSON"})); +assert.doesNotMatch(json, /
/, "File suffix does not turn JSON evidence into a report"); +console.log("report rendering: readable table, raw fidelity, safe links and inert HTML, chat parity passed"); 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 bcbc4e1cc..d6edf1e9c 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 @@ -1,5 +1,6 @@ import { useEffect, useId, useRef, useState } from "react"; import { z } from "zod"; +import {compactWorkspaceText} from "./personal-workspace-model"; import type { WorkspaceAgentTodo, WorkspaceDrawerSelection, WorkspaceGoal } from "./personal-workspace-model"; import { useWorkspaceI18n } from "./i18n"; @@ -75,7 +76,7 @@ export function CompletedTaskLane({ goal, agentId, seed, enabled, listView = fal const todo = rows[index]!; return
; })} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css index d36b53396..307bcb600 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css @@ -53,3 +53,59 @@ .goal-team-lineage ul { list-style: none; padding: 0; margin: 12px 0; } .goal-team-lineage li { display: grid; gap: 8px; padding: 0 0 16px 16px; border-left: 2px solid var(--pw-line, #ebebeb); overflow-wrap: anywhere; } .goal-team-lineage button { justify-self: start; max-width: 100%; overflow-wrap: anywhere; text-align: start; } +.goal-loopx-dialog:has(.goal-team-comparison) { width: min(1040px, calc(100vw - 32px)); } +.goal-team-comparison { margin-block: 24px; } +.goal-team-comparison-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; } +.goal-team-comparison-heading h4 { font-size: 20px; letter-spacing: -.02em; } +.goal-team-comparison-heading > span { font-size: 12px; color: var(--pw-muted, #666); } +.goal-team-comparison-sources { display: flex; gap: 8px; flex-wrap: wrap; margin-block: 16px; } +.goal-team-comparison-sources button { flex-direction: column; align-items: flex-start; max-width: 100%; overflow-wrap: anywhere; text-align: left; } +.goal-team-comparison-sources button > span { color: var(--pw-muted, #666); font-size: 12px; } +.goal-team-comparison-sources button[aria-pressed="true"] { border-color: var(--pw-text, #171717); } +.goal-team-comparison-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.goal-team-comparison-grid article { margin: 0; min-width: 0; border: 1px solid var(--pw-line, #ebebeb); border-radius: 12px; overflow: hidden; } +.goal-team-comparison-grid header { display: grid; gap: 4px; padding: 16px; border-bottom: 1px solid var(--pw-line, #ebebeb); overflow-wrap: anywhere; } +.goal-team-comparison-grid header span { color: var(--pw-muted, #666); font-size: 12px; } +.goal-team-comparison-grid pre { margin: 0; max-height: 400px; border: 0; border-radius: 0; padding: 16px; font-size: 13px; line-height: 1.8; } +.goal-team-comparison-grid pre span[data-changed="true"] { background: color-mix(in srgb, var(--pw-text, #171717) 7%, transparent); text-decoration: underline; text-decoration-color: var(--pw-muted, #666); text-underline-offset: 4px; } +.goal-team-comparison select { min-height: 44px; padding: 8px; background: var(--pw-surface, #fff); color: inherit; border: 1px solid var(--pw-line, #ebebeb); border-radius: 6px; } +@media (max-width: 640px) { .goal-team-comparison-grid { grid-template-columns: 1fr; } } + +.goal-team-artifact > header { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; } +.goal-team-artifact > header h4 { font-size: 12px; color: var(--pw-muted); font-weight: 500; } +.goal-team-report { padding: 24px; background: var(--pw-surface, #fff); color: var(--pw-text, #171717); overflow-wrap: anywhere; } +.goal-team-report .personal-md { font-size: 15px; line-height: 1.8; gap: 20px; } +.goal-team-report .personal-md p { font-size: inherit; line-height: inherit; color: inherit; margin: 0; } +.goal-team-report .personal-md .is-h1 { font-size: 26px; line-height: 1.3; font-weight: 600; letter-spacing: -.03em; } +.goal-team-report .personal-md .is-h2 { font-size: 18px; font-weight: 600; margin-top: 8px; } +.goal-team-report .personal-md-list { list-style: revert; margin: 0; padding-left: 24px; } +.goal-team-report .personal-md-list li { border: 0; padding: 0; } +.personal-md-table-scroll { min-width: 0; max-width: 100%; overflow-x: auto; } +.personal-md-table-scroll table { width: 100%; border-collapse: collapse; font-size: 14px; line-height: 1.6; } +.personal-md-table-scroll th, .personal-md-table-scroll td { padding: 12px; border-bottom: 1px solid var(--pw-line, #ebebeb); text-align: left; vertical-align: top; } +.personal-md-table-scroll th { font-weight: 500; background: var(--pw-bg, #fafafa); } +.goal-team-comparison-grid .goal-team-report { max-height: 480px; overflow: auto; } +.goal-team-comparison-grid .goal-team-report .personal-md { font-size: 14px; } +.goal-team-results { width: min(960px, 100%); margin: 16px auto 24px; padding: 24px; border: 1px solid var(--pw-line, #ebebeb); border-radius: 12px; background: var(--pw-surface, #fff); color: var(--pw-text, #171717); } +.goal-team-results > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.goal-team-results h3 { font-size: 16px; font-weight: 600; margin: 0; } +.goal-team-results p { font-size: 12px; line-height: 1.7; color: var(--pw-muted); } +.goal-team-results [role="alert"] { color: var(--pw-red, #b42318); } +.goal-team-results button, .goal-team-results select { min-height: 44px; padding: 8px 12px; border: 1px solid var(--pw-line, #ebebeb); border-radius: 6px; background: var(--pw-surface, #fff); color: inherit; font: inherit; cursor: pointer; } +.goal-team-results button[aria-pressed="true"] { border-color: var(--pw-text); } +.goal-team-results button:disabled { opacity: .5; cursor: default; } +.goal-team-results summary { cursor: pointer; padding: 12px 0; font-size: 12px; min-height: 44px; } +.goal-team-results code { overflow-wrap: anywhere; } +.goal-team-report table code { white-space: pre; overflow-wrap: normal; word-break: normal; } +.goal-team-results pre { padding: 16px; max-height: 400px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: 13px/1.7 var(--font-mono, monospace); background: var(--pw-bg); } +.goal-team-result-list { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 16px; } +.goal-team-result-list button { max-width: 100%; overflow-wrap: anywhere; font-size: 13px; text-align: start; } +.goal-team-result-reader { border-top: 1px solid var(--pw-line); margin-top: 16px; padding-top: 16px; } +.goal-team-results .goal-team-artifact > header { margin-bottom: 0; } +.goal-team-results :focus-visible, .goal-team-report:focus-visible, .personal-md-table-scroll:focus-visible { outline: 2px solid var(--pw-blue-ink, #0070f3); outline-offset: 2px; } +@media (max-width: 640px) { + .goal-team-results { padding: 16px; } + .goal-team-report { padding: 16px 0; } + .goal-team-comparison-grid .goal-team-report { padding: 16px; } + .goal-team-report .personal-md .is-h1 { font-size: 22px; } +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-comparison.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-comparison.tsx new file mode 100644 index 000000000..fccfde697 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-comparison.tsx @@ -0,0 +1,74 @@ +import {useEffect, useRef, useState} from "react"; +import {readLoopXTeamWork, type DelegationDependency, type DelegationReadback} from "../../data/chat"; +import {changedRange, comparisonSource, preferredComparisonIndex} from "./team-artifact-comparison"; +import {TeamArtifactContent} from "./team-artifact-content"; + +export function GoalTeamComparison({sessionId, result, zh}: { + sessionId: string; result: DelegationReadback; zh: boolean; +}) { + const [selection, setSelection] = useState<{link: DelegationDependency; source: DelegationReadback} | null>(null); + const [outputIndex, setOutputIndex] = useState(0); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [raw, setRaw] = useState(false); + const generation = useRef(0); + useEffect(() => { + generation.current++; setSelection(null); setError(""); setBusy(false); setOutputIndex(0); + return () => {generation.current++;}; + }, [result, sessionId]); + const artifacts = result.status === "accepted" && !result.error && !result.recovery_required ? result.artifacts ?? [] : []; + const links = result.dependencies ?? []; + async function compare(link: DelegationDependency) { + const current = ++generation.current; + setSelection(null); setError(""); setBusy(true); setOutputIndex(preferredComparisonIndex(link.ref, artifacts)); setRaw(false); + try { + const source = await readLoopXTeamWork(sessionId, link.operation_id); + if (current !== generation.current) return; + if (!comparisonSource(link, source)) { + setError(zh ? "指定来源版本无法核验,未展示对照。" : "The referenced source version cannot be verified. Comparison withheld."); + } else setSelection({link, source}); + } catch { + if (current === generation.current) setError(zh ? "来源读取失败,已清除上次对照。可重试或查看执行详情。" : "Source read failed; previous comparison cleared. Retry or inspect the execution."); + } finally {if (current === generation.current) setBusy(false);} + } + if (!links.length || !artifacts.length) return null; + const before = selection ? comparisonSource(selection.link, selection.source) : null; + const after = artifacts[outputIndex]; + const range = before && after ? changedRange(before.text, after.text) : null; + const labels = zh ? {revises: "修订依据", responds_to: "回应依据", uses: "使用依据"} + : {revises: "Revision source", responds_to: "Response source", uses: "Input source"}; + return
+

{zh ? "看清这次变化" : "See what changed"}

+ {zh ? "按需核验" : "On-demand verification"}
+
{links.map((link, index) => )}
+ {busy ?

{zh ? "正在读取指定来源版本…" : "Reading the referenced source version…"}

: null} + {error ?

{error}

: null} + {before && after && range ? <> + + {artifacts.length > 1 ? : null} +
+ {([{kind: "source", title: zh ? "指定来源" : "Referenced source", artifact: before, lines: range.left, end: range.leftEnd}, + {kind: "result", title: zh ? "本次产物" : "This output", artifact: after, lines: range.right, end: range.rightEnd}]).map(pane =>
+
{pane.title}{pane.artifact.ref}
+ {raw ?
{pane.lines.map((line, i) => = range.start && i < pane.end}>{line}{i < pane.lines.length - 1 ? "\n" : ""})}
+ : } +
)} +
+

{before.text === after.text ? (zh ? "两个版本的文本相同。" : "Both versions contain the same text.") + : raw ? (zh ? "标记文本变化所在范围;不代表结论正确或已被采用。" : "Marks the range containing text changes; does not establish correctness or adoption.") + : (zh ? "正文对照;验收与采用见版本关系。" : "Read the reports; see lineage for acceptance and adoption.")}

+
{zh ? "核验的版本" : "Verified versions"} + {before.ref} · sha256:{before.sha256}{after.ref} · sha256:{after.sha256} +
+ :

{zh ? "选择一份依据,与本次产物并排阅读。" : "Choose a source to read beside this output."}

} +
; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx index 054b9f458..6b7033aef 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-team-evidence.tsx @@ -1,4 +1,6 @@ import {GoalTeamLineage} from "./goal-team-lineage"; +import {GoalTeamComparison} from "./goal-team-comparison"; +import {TeamArtifactReport} from "./team-artifact-content"; import {useEffect, useRef, useState} from "react"; import {delegationStateLabel, readLoopXTeamWork, sendLoopXMessage, type DelegationReadback, type LoopXModeSnapshot} from "../../data/chat"; @@ -56,21 +58,20 @@ export function GoalTeamEvidence({sessionId, operationId, zh, canMessage, ingres return

{zh ? "执行证据" : "Execution evidence"}

- {operationId} {busy ?

{zh ? "正在核验绑定、验收与文件…" : "Checking bindings, acceptance and files…"}

: null} {error ?

{zh ? "无法核验,已清除上次证据。" : "Cannot verify; previous evidence cleared."} {error}

: null} {result ? <>

{result.agent_id} · {delegationStateLabel(result, zh)}{" · "}{observedAt}

{zh ? "按需读取的当前观察,不是持续在线状态;验收不代表协调员已采用。" : "An on-demand observation, not continuous liveness; acceptance does not establish coordinator adoption."}

+ {result.error ?

{result.error}

: null} - {result.status === "accepted" && result.artifacts?.length ? result.artifacts.map(artifact =>
-

{artifact.ref}

-
{artifact.text}
+ {result.status === "accepted" && !result.error && !result.recovery_required && result.artifacts?.length ? result.artifacts.map(artifact =>
+
{zh ? "版本与来源标识" : "Version and source identifiers"} - sha256:{artifact.sha256}{result.request_id}{result.todo_id} + {operationId}{result.request_id}{result.todo_id}
-
) :

{zh ? "本次读取没有可展示的已验收产物。" : "No accepted artifact is available in this readback."}

} + ) :

{zh ? "本次读取没有可展示的已验收产物。" : "No accepted artifact is available in this readback."}

}
{event.preventDefault(); void send();}}>