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:
+19
-3
@@ -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请帮我审查其合规性、技术可行性或提出修改建议。`
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user