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:
@@ -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' }}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user