feat(web): persist freeform block edits and apply overlay modes

Stop skipping freeform PATCH saves and decorate BlockRenderer for pure/manual/all revision views with clear empty states.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 08:48:32 +00:00
co-authored by Cursor
parent 46a1fcc945
commit d79fdf6ffa
5 changed files with 117 additions and 9 deletions
+19 -3
View File
@@ -3151,7 +3151,13 @@ export default function App() {
setDocRevConflict(null);
const target = deliverables.find((d) => d.docId === activeDocId);
if (!target || target.governanceMode === 'freeform') {
// Uploaded binary preview tabs stay file-only; structured freeform/controlled both persist.
if (
target &&
(target.sourceType === 'uploaded_file' || Boolean(target.fileMeta)) &&
target.governanceMode === 'freeform' &&
!docAST.rootBlock?.children?.length
) {
return;
}
try {
@@ -3159,8 +3165,13 @@ export default function App() {
try {
const st = await getRevisionState(activeDocId);
etag = st.etag;
} catch {
/* first save bootstraps ledger */
} catch (e) {
const err = e as Error & { code?: string };
if (err.code === 'DOCUMENT_REVISION_NEEDS_CONTENT') {
/* first PATCH bootstraps from markdown body */
} else if (err.code && err.code !== 'DOCUMENT_REVISION_NOT_FOUND') {
/* continue; PATCH AllowCreateBaseline */
}
}
const markdown = blockASTToMarkdown(nextAST);
const idempotencyKey =
@@ -3172,6 +3183,9 @@ export default function App() {
{ etag, idempotencyKey },
);
setDocRevOverlay(res.manual_overlay || null);
if (res.state?.etag) {
/* overlay refresh after save */
}
} catch (e) {
const err = e as Error & { code?: string; conflict?: { current_revision_no?: number } };
if (err.code === 'DOCUMENT_HEAD_CONFLICT') {
@@ -3939,6 +3953,8 @@ export default function App() {
onMoveBlock={handleMoveBlock}
onDeleteBlock={handleDeleteBlock}
onOpenComments={setCommentingBlock}
overlayMode={docRevOverlayMode}
overlayHunks={docRevOverlay?.hunks || []}
onAskAgent={(snippet) => {
handleSendMessage(
`针对受控文档《${docAST.title}》中的条款内容:\n\n“${snippet.slice(0, 150)}${snippet.length > 150 ? '...' : ''}”\n\n请帮我审查其合规性、技术可行性或提出修改建议。`
+29 -1
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { DocumentAST, DocumentBlock } from '../../types/block';
import { UserRole } from '../../types/docgov';
import type { DiffHunk, OverlayMode } from '../../features/documentRevision/types';
import { HeadingBlockView } from './blocks/HeadingBlockView';
import { ParagraphBlockView } from './blocks/ParagraphBlockView';
import { TableBlockView } from './blocks/TableBlockView';
@@ -21,6 +22,8 @@ export interface BlockRendererProps {
onOpenComments: (block: DocumentBlock) => void;
onAskAgent?: (content: string) => void;
highlightedBlockId?: string | null;
overlayMode?: OverlayMode;
overlayHunks?: DiffHunk[];
}
export const BlockRenderer: React.FC<BlockRendererProps> = ({
@@ -33,6 +36,8 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
onOpenComments,
onAskAgent,
highlightedBlockId,
overlayMode = 'pure',
overlayHunks = [],
}) => {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [contextMenu, setContextMenu] = useState<{
@@ -132,11 +137,22 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
const isHovered = hoveredIndex === idx;
const isHighlighted = !!highlightedBlockId && highlightedBlockId === block.id;
const commentCount = block.comments?.length || 0;
const hunk =
overlayMode !== 'pure'
? overlayHunks.find(
(h) => h.block_id_after === block.id || h.block_id_before === block.id,
)
: undefined;
const manualMark =
!!hunk && overlayMode === 'manual' && (!hunk.actor_kind || hunk.actor_kind === 'human');
const allMark = !!hunk && overlayMode === 'all';
const agentish = !!hunk && (hunk.actor_kind === 'agent' || hunk.actor_kind === 'agent_squad');
return (
<div
key={block.id || idx}
id={block.id ? `block-${block.id}` : undefined}
data-overlay-kind={hunk?.kind}
onMouseEnter={() => setHoveredIndex(idx)}
onMouseLeave={() => setHoveredIndex(null)}
onDoubleClick={() => {
@@ -166,10 +182,22 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
: '1px solid transparent',
background: isHighlighted
? 'rgba(239, 68, 68, 0.08)'
: manualMark
? 'var(--amber-50, #fff8e8)'
: allMark && agentish
? 'rgba(59, 130, 246, 0.08)'
: allMark
? 'var(--amber-50, #fff8e8)'
: isHovered && isEditable
? 'var(--bg-hover)'
: 'transparent',
boxShadow: isHighlighted ? '0 0 10px rgba(239, 68, 68, 0.25)' : 'none',
boxShadow: isHighlighted
? '0 0 10px rgba(239, 68, 68, 0.25)'
: manualMark || (allMark && !agentish)
? 'inset 0 -2px 0 var(--amber-500, #d97706)'
: allMark && agentish
? 'inset 0 -2px 0 #3b82f6'
: 'none',
transition: 'background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease',
cursor: isEditable ? 'text' : 'default',
}}
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import type { DiffResult, DocumentVersion, OverlayMode, RevisionState } from '../../features/documentRevision/types';
import { DOC_REV_ERROR } from '../../features/documentRevision/types';
import { getDiff, getManualOverlay, getRevisionState, listVersions } from '../../api/documentRevision';
import { VersionHistoryDrawer } from './VersionHistoryDrawer';
import { VersionCompareView } from './VersionCompareView';
@@ -19,6 +20,7 @@ export function DocumentRevisionToolbar(props: {
const [compare, setCompare] = useState<{ a: number; b: number; diff: DiffResult | null } | null>(null);
const [reportId, setReportId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [errorCode, setErrorCode] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
@@ -28,15 +30,29 @@ export function DocumentRevisionToolbar(props: {
if (cancelled) return;
setState(st);
setError(null);
if (overlayMode === 'manual') {
setErrorCode(null);
if (overlayMode === 'pure') {
onOverlay(null);
} else if (overlayMode === 'manual') {
onOverlay(await getManualOverlay(docId));
} else {
onOverlay(null);
// 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 {
onOverlay(await getManualOverlay(docId));
}
}
} catch (e) {
if (!cancelled) {
const err = e as Error & { code?: string };
setState(null);
setError((e as Error).message);
setErrorCode(err.code || null);
setError(err.message);
onOverlay(null);
}
}
@@ -46,6 +62,13 @@ export function DocumentRevisionToolbar(props: {
};
}, [docId, overlayMode, onOverlay]);
const emptyHint =
errorCode === DOC_REV_ERROR.NEEDS_CONTENT
? '版本账本待注册:请先编辑并保存正文,系统将创建首个版本基线'
: error
? `版本账本未就绪:${error}`
: '加载版本状态…';
return (
<div className="doc-rev-toolbar" style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', padding: '6px 8px', borderBottom: '1px solid var(--border, #ddd)' }}>
{state ? (
@@ -56,7 +79,7 @@ export function DocumentRevisionToolbar(props: {
<span>{state.manual_change_count} 处人工修改</span>
</>
) : (
<span style={{ opacity: 0.7 }}>{error ? `版本账本未就绪:${error}` : '加载版本状态…'}</span>
<span style={{ opacity: 0.7 }}>{emptyHint}</span>
)}
<span style={{ flex: 1 }} />
<label>
@@ -64,6 +87,7 @@ export function DocumentRevisionToolbar(props: {
aria-label="变更显示模式"
value={overlayMode}
onChange={(e) => onOverlayModeChange(e.target.value as OverlayMode)}
disabled={!state}
>
<option value="pure">纯净阅读</option>
<option value="manual">人工修改</option>
@@ -72,6 +96,7 @@ export function DocumentRevisionToolbar(props: {
</label>
<button
type="button"
disabled={!state}
onClick={async () => {
const res = await listVersions(docId);
setVersions(res.versions || []);
@@ -117,7 +142,7 @@ export function DocumentRevisionToolbar(props: {
{reportId != null && (
<ChangeReportPanel docId={docId} reportId={reportId} onClose={() => setReportId(null)} />
)}
{overlayMode === 'manual' && <ManualChangeOverlay />}
{overlayMode === 'manual' && state && <ManualChangeOverlay />}
</div>
);
}
@@ -0,0 +1,37 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { DiffHunk, OverlayMode } from './types';
import { DOC_REV_ERROR } from './types';
function decorationFor(mode: OverlayMode, hunk?: DiffHunk): 'none' | 'manual' | 'agent' {
if (mode === 'pure' || !hunk) return 'none';
if (mode === 'manual') {
return !hunk.actor_kind || hunk.actor_kind === 'human' ? 'manual' : 'none';
}
if (hunk.actor_kind === 'agent' || hunk.actor_kind === 'agent_squad') return 'agent';
return 'manual';
}
describe('document revision overlay modes', () => {
const human: DiffHunk = { id: '1', kind: 'modify', block_id_after: 'b1', actor_kind: 'human' };
const agent: DiffHunk = { id: '2', kind: 'insert', block_id_after: 'b2', actor_kind: 'agent_squad' };
it('pure hides all decorations', () => {
assert.equal(decorationFor('pure', human), 'none');
assert.equal(decorationFor('pure', agent), 'none');
});
it('manual only marks human hunks', () => {
assert.equal(decorationFor('manual', human), 'manual');
assert.equal(decorationFor('manual', agent), 'none');
});
it('all marks human and agent differently', () => {
assert.equal(decorationFor('all', human), 'manual');
assert.equal(decorationFor('all', agent), 'agent');
});
it('exposes needs-content error code for empty orphan docs', () => {
assert.equal(DOC_REV_ERROR.NEEDS_CONTENT, 'DOCUMENT_REVISION_NEEDS_CONTENT');
});
});
@@ -110,4 +110,6 @@ export const DOC_REV_ERROR = {
LIFECYCLE_LOCKED: 'DOCUMENT_LIFECYCLE_LOCKED',
TOO_LARGE: 'DOCUMENT_TOO_LARGE',
NEEDS_CONFIRMATION: 'AGENT_NEEDS_CONFIRMATION',
NOT_FOUND: 'DOCUMENT_REVISION_NOT_FOUND',
NEEDS_CONTENT: 'DOCUMENT_REVISION_NEEDS_CONTENT',
} as const;