feat(web): refresh revision toolbar after save and expose report entry

Bump revisionRefreshKey on save/hydrate so r and overlay reload; add a
latest change-report button wired to version change_report_id.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 09:27:07 +00:00
co-authored by Cursor
parent 75ec8785f3
commit ff1a126415
6 changed files with 190 additions and 23 deletions
+6
View File
@@ -680,6 +680,8 @@ export default function App() {
const [docRevOverlayMode, setDocRevOverlayMode] = useState<OverlayMode>('manual');
const [docRevOverlay, setDocRevOverlay] = useState<DiffResult | null>(null);
const [docRevConflict, setDocRevConflict] = useState<string | null>(null);
/** 人工保存 / 受控正文 hydrate 后递增,驱动工具条重新拉取 revision-state 与 overlay */
const [docRevRefreshKey, setDocRevRefreshKey] = useState(0);
const [deckDrawerOpen, setDeckDrawerOpen] = useState(false);
// 认证与会话状态
@@ -1123,6 +1125,7 @@ export default function App() {
setDocCache((prev) => ({ ...prev, [activeDocId]: ast }));
setControlledContentReady((prev) => ({ ...prev, [activeDocId]: true }));
setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: false }));
setDocRevRefreshKey((k) => k + 1);
})
.catch((err) => {
console.warn('自动拉取受控文档正文失败:', err);
@@ -1160,6 +1163,7 @@ export default function App() {
setDocCache((prev) => ({ ...prev, [activeDocId]: ast }));
setControlledContentReady((prev) => ({ ...prev, [activeDocId]: true }));
setDocLoadErrors((prev) => ({ ...prev, [activeDocId]: false }));
setDocRevRefreshKey((k) => k + 1);
})
.catch((err) => {
console.warn('重试拉取受控文档正文失败:', err);
@@ -3192,6 +3196,7 @@ export default function App() {
setEditingBlock(null);
setDocRevConflict(null);
setDocRevOverlay(res.manual_overlay || null);
setDocRevRefreshKey((k) => k + 1);
} catch (e) {
const err = e as Error & { code?: string; conflict?: { current_revision_no?: number } };
if (err.code === 'DOCUMENT_HEAD_CONFLICT') {
@@ -3864,6 +3869,7 @@ export default function App() {
overlayMode={docRevOverlayMode}
onOverlayModeChange={setDocRevOverlayMode}
onOverlay={setDocRevOverlay}
revisionRefreshKey={docRevRefreshKey}
/>
{docRevConflict && (
<div role="alert" style={{ padding: '8px 12px', background: '#fff3cd', borderBottom: '1px solid #e0c36a' }}>
+72 -7
View File
@@ -7,19 +7,84 @@ export function ChangeReportPanel(props: { docId: string; reportId: number; onCl
useEffect(() => {
getChangeReport(props.docId, props.reportId).then(setReport).catch(() => setReport(null));
}, [props.docId, props.reportId]);
const projectSummary =
report?.project_impacts && typeof report.project_impacts.summary === 'string'
? String(report.project_impacts.summary)
: null;
return (
<div role="dialog" aria-label="变更报告" style={{ position: 'fixed', right: 16, bottom: 16, width: 360, maxWidth: '90vw', background: '#fff', border: '1px solid #ccc', zIndex: 60, padding: 12 }}>
<div
role="dialog"
aria-label="变更报告"
data-testid="change-report-panel"
style={{
position: 'fixed',
right: 16,
bottom: 16,
width: 400,
maxWidth: '90vw',
maxHeight: '70vh',
overflow: 'auto',
background: '#fff',
border: '1px solid #ccc',
zIndex: 60,
padding: 12,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<h3>变更报告</h3>
<button type="button" onClick={props.onClose}>关闭</button>
<button type="button" onClick={props.onClose}>
关闭
</button>
</div>
{!report ? <p>加载中…</p> : (
{!report ? (
<p>加载中…</p>
) : (
<>
<p>风险:<strong>{report.risk_level}</strong> · {report.analysis_status}</p>
<ul>{report.summary.map((s) => <li key={s}>{s}</li>)}</ul>
{report.document_impacts?.length > 0 && (
<p>影响文档 {report.document_impacts.length} 份</p>
<p>
风险:<strong data-testid="change-report-risk">{report.risk_level}</strong>
{' · '}
<span data-testid="change-report-analysis">{report.analysis_status}</span>
{report.analysis_status === 'fallback' || report.analysis_status === 'unassessed' ? (
<span style={{ marginLeft: 6, opacity: 0.75 }}>(确定性分析,非完整 PI 叙事)</span>
) : null}
</p>
<h4>摘要</h4>
<ul data-testid="change-report-summary">
{(report.summary || []).map((s) => (
<li key={s}>{s}</li>
))}
</ul>
{projectSummary ? (
<p data-testid="change-report-project-impact">
项目影响:{projectSummary}
</p>
) : null}
{report.document_impacts?.length > 0 ? (
<div data-testid="change-report-doc-impacts">
<h4>文档影响</h4>
<ul>
{report.document_impacts.map((d) => (
<li key={`${d.doc_id}-${d.impact_type}`}>
{d.doc_id}:{d.reason || d.impact_type}
</li>
))}
</ul>
</div>
) : (
<p style={{ opacity: 0.75 }}>文档影响:无(或尚未登记下游 lineage)</p>
)}
{report.open_questions?.length > 0 ? (
<div data-testid="change-report-open-questions">
<h4>开放问题</h4>
<ul>
{report.open_questions.map((q) => (
<li key={q}>{q}</li>
))}
</ul>
</div>
) : null}
</>
)}
</div>
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import type { DiffResult, DocumentVersion, OverlayMode, RevisionState } from '../../features/documentRevision/types';
import { DOC_REV_ERROR } from '../../features/documentRevision/types';
import { pickLatestChangeReportId } from '../../features/documentRevision/toolbarRefresh';
import { getDiff, getManualOverlay, getRevisionState, listVersions } from '../../api/documentRevision';
import { VersionHistoryDrawer } from './VersionHistoryDrawer';
import { VersionCompareView } from './VersionCompareView';
@@ -12,10 +13,13 @@ export function DocumentRevisionToolbar(props: {
overlayMode: OverlayMode;
onOverlayModeChange: (m: OverlayMode) => void;
onOverlay: (d: DiffResult | null) => void;
/** Bump after human save / squad hydrate so r 与 overlay 重新拉取(勿仅依赖 docId)。 */
revisionRefreshKey?: number;
}) {
const { docId, overlayMode, onOverlayModeChange, onOverlay } = props;
const { docId, overlayMode, onOverlayModeChange, onOverlay, revisionRefreshKey = 0 } = props;
const [state, setState] = useState<RevisionState | null>(null);
const [versions, setVersions] = useState<DocumentVersion[]>([]);
const [latestReportId, setLatestReportId] = useState<number | null>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const [compare, setCompare] = useState<{ a: number; b: number; diff: DiffResult | null } | null>(null);
const [reportId, setReportId] = useState<number | null>(null);
@@ -31,26 +35,37 @@ export function DocumentRevisionToolbar(props: {
setState(st);
setError(null);
setErrorCode(null);
try {
const res = await listVersions(docId);
if (cancelled) return;
const vs = res.versions || [];
setVersions(vs);
setLatestReportId(pickLatestChangeReportId(vs));
} catch {
if (!cancelled) {
setVersions([]);
setLatestReportId(null);
}
}
if (overlayMode === 'pure') {
onOverlay(null);
} else if (overlayMode === 'manual') {
onOverlay(await getManualOverlay(docId));
} else {
// all: prefer last sealed→current when possible, else manual overlay
if (st.current_version_no >= 2) {
try {
onOverlay(await getDiff(docId, st.current_version_no - 1, st.current_version_no));
} catch {
onOverlay(await getManualOverlay(docId));
}
} else {
} else if (st.current_version_no >= 2) {
try {
onOverlay(await getDiff(docId, st.current_version_no - 1, st.current_version_no));
} catch {
onOverlay(await getManualOverlay(docId));
}
} else {
onOverlay(await getManualOverlay(docId));
}
} catch (e) {
if (!cancelled) {
const err = e as Error & { code?: string };
setState(null);
setVersions([]);
setLatestReportId(null);
setErrorCode(err.code || null);
setError(err.message);
onOverlay(null);
@@ -60,7 +75,7 @@ export function DocumentRevisionToolbar(props: {
return () => {
cancelled = true;
};
}, [docId, overlayMode, onOverlay]);
}, [docId, overlayMode, onOverlay, revisionRefreshKey]);
const emptyHint =
errorCode === DOC_REV_ERROR.NEEDS_CONTENT
@@ -73,10 +88,10 @@ export function DocumentRevisionToolbar(props: {
<div className="doc-rev-toolbar" style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', padding: '6px 8px', borderBottom: '1px solid var(--border, #ddd)' }}>
{state ? (
<>
<strong>V{state.current_version_no}</strong>
<strong data-testid="doc-rev-version">V{state.current_version_no}</strong>
<span>{state.version_status === 'open' ? '当前' : '已封存'}</span>
<span>r{state.head_revision_no}</span>
<span>{state.manual_change_count} 处人工修改</span>
<span data-testid="doc-rev-revision">r{state.head_revision_no}</span>
<span data-testid="doc-rev-manual-count">{state.manual_change_count} 处人工修改</span>
</>
) : (
<span style={{ opacity: 0.7 }}>{emptyHint}</span>
@@ -99,12 +114,23 @@ export function DocumentRevisionToolbar(props: {
disabled={!state}
onClick={async () => {
const res = await listVersions(docId);
setVersions(res.versions || []);
const vs = res.versions || [];
setVersions(vs);
setLatestReportId(pickLatestChangeReportId(vs));
setHistoryOpen(true);
}}
>
版本历史
</button>
<button
type="button"
data-testid="doc-rev-latest-report"
disabled={latestReportId == null}
title={latestReportId == null ? '尚无智能体变更报告(需至少一次小队成功提交)' : '打开最新变更报告'}
onClick={() => latestReportId != null && setReportId(latestReportId)}
>
变更报告
</button>
<button
type="button"
disabled={!state}
@@ -1,8 +1,9 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { DiffHunk, OverlayMode } from './types';
import type { DiffHunk, DocumentVersion, OverlayMode } from './types';
import { DOC_REV_ERROR } from './types';
import { applyHeadBlockMap, hunkMatchesBlock } from './overlayMatch';
import { countVersionsWithReports, pickLatestChangeReportId } from './toolbarRefresh';
import type { DocumentAST, DocumentBlock } from '../../types/block';
function decorationFor(mode: OverlayMode, hunk?: DiffHunk): 'none' | 'manual' | 'agent' {
@@ -72,3 +73,53 @@ describe('document revision overlay modes', () => {
assert.equal(aligned.rootBlock.children?.[0].id, 'blk_aa');
});
});
describe('toolbar refresh + report entry reachability', () => {
const base = (partial: Partial<DocumentVersion> & Pick<DocumentVersion, 'id' | 'version_no'>): DocumentVersion => ({
doc_id: 'doc1',
status: 'sealed',
origin_kind: 'agent_squad',
baseline_snapshot_id: 1,
head_snapshot_id: 1,
human_revision_count: 0,
opened_at: '2026-09-13T00:00:00Z',
...partial,
});
it('pickLatestChangeReportId prefers highest version with report', () => {
const versions = [
base({ id: 1, version_no: 1, origin_kind: 'template' }),
base({ id: 2, version_no: 2, change_report_id: 10 }),
base({ id: 3, version_no: 3, change_report_id: 20, status: 'open' }),
];
assert.equal(pickLatestChangeReportId(versions), 20);
assert.equal(countVersionsWithReports(versions), 2);
});
it('first squad run alone still exposes a reachable report id', () => {
const afterFirst = [
base({ id: 1, version_no: 1, origin_kind: 'template' }),
base({ id: 2, version_no: 2, change_report_id: 11, status: 'open' }),
];
assert.equal(pickLatestChangeReportId(afterFirst), 11);
assert.equal(countVersionsWithReports(afterFirst), 1);
});
it('second squad run keeps both history report entries and latest shortcut', () => {
const afterSecond = [
base({ id: 1, version_no: 1, origin_kind: 'template' }),
base({ id: 2, version_no: 2, change_report_id: 11 }),
base({ id: 3, version_no: 3, change_report_id: 22, status: 'open' }),
];
assert.equal(pickLatestChangeReportId(afterSecond), 22);
assert.equal(countVersionsWithReports(afterSecond), 2);
});
it('revisionRefreshKey is an independent refresh signal from docId', () => {
// Contract for DocumentRevisionToolbar effect deps: save must bump key even when docId unchanged.
const depsBefore = { docId: 'doc_a', overlayMode: 'manual', refreshKey: 0 };
const depsAfterSave = { docId: 'doc_a', overlayMode: 'manual', refreshKey: 1 };
assert.equal(depsBefore.docId, depsAfterSave.docId);
assert.notEqual(depsBefore.refreshKey, depsAfterSave.refreshKey);
});
});
@@ -0,0 +1,18 @@
import type { DocumentVersion } from './types';
/** Highest version_no that carries a change_report_id (agent/squad commits). */
export function pickLatestChangeReportId(versions: DocumentVersion[]): number | null {
let best: { versionNo: number; reportId: number } | null = null;
for (const v of versions) {
if (v.change_report_id == null || v.change_report_id <= 0) continue;
if (!best || v.version_no > best.versionNo) {
best = { versionNo: v.version_no, reportId: v.change_report_id };
}
}
return best?.reportId ?? null;
}
/** Count versions that expose a report entry (first + second squad runs should each add one). */
export function countVersionsWithReports(versions: DocumentVersion[]): number {
return versions.filter((v) => v.change_report_id != null && v.change_report_id > 0).length;
}
@@ -83,6 +83,7 @@ export interface ChangeReport {
summary: string[];
risk_level: RiskLevel;
risk_items: { category: string; description: string; mitigation?: string }[];
project_impacts?: Record<string, unknown>;
document_impacts: { doc_id: string; impact_type: string; reason: string; recommended_action?: string }[];
open_questions: string[];
analysis_status: string;