feat(web): replace the standing trace panel with a chat card

The requirements and impact summary no longer occupies the chat column. Open recheck marks arrive as an assistant card with a no-change action.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-21 05:19:43 +00:00
co-authored by Cursor
parent 687f930f40
commit d865342ae2
7 changed files with 56 additions and 83 deletions
-2
View File
@@ -34,7 +34,6 @@ import {
import { DocumentLineage } from './types/projectWorkflow';
import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice';
import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner';
import { ProjectTracePanel } from './components/workflow/ProjectTracePanel';
import { OnDemand } from './components/lazy/OnDemand';
import { fetchProjectSettings } from './api/projectSettings';
import { LoginView } from './components/auth/LoginView';
@@ -435,7 +434,6 @@ export default function App() {
isCreator={currentWfState.creatorId === currentUser.id}
/>
)}
{currentProjectId > 0 && <ProjectTracePanel projectId={currentProjectId} />}
<StaleDocumentNotice
lineages={projectLineages}
onTriggerRecheck={() => {
+7 -2
View File
@@ -49,11 +49,16 @@ export async function fetchProjectTrace(projectId: number): Promise<ProjectTrace
};
}
export async function clearRecheckMark(projectId: number, markId: number, reason: 'no_change' | 'revision_started'): Promise<void> {
export async function clearRecheckMark(
projectId: number,
markId: number,
reason: 'no_change' | 'revision_started',
messageId?: number,
): Promise<void> {
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/trace/recheck/${markId}/clear`, {
method: 'POST',
headers: getBearerAuthHeaders('application/json'),
body: JSON.stringify({ reason }),
body: JSON.stringify({ reason, message_id: messageId || 0 }),
});
if (!res.ok) {
throw new Error(`关闭待复核失败: ${res.status}`);
@@ -82,6 +82,16 @@ export function getCardTypeMeta(type: InteractiveCardType) {
badgeColor: 'var(--info)',
badgeBorder: 'rgba(43, 108, 201, 0.2)',
};
case 'RECHECK_NOTICE':
return {
label: '待复核',
badgeText: '待复核',
icon: AlertTriangle,
accentColor: 'var(--amber-500)',
badgeBg: 'var(--amber-50)',
badgeColor: 'var(--amber-600)',
badgeBorder: 'rgba(242, 169, 59, 0.3)',
};
default:
return {
label: '协同卡片',
@@ -1,78 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import { clearRecheckMark, fetchProjectTrace, ProjectTraceView } from '../../api/projectTrace';
interface ProjectTracePanelProps {
projectId: number;
}
export const ProjectTracePanel: React.FC<ProjectTracePanelProps> = ({ projectId }) => {
const [view, setView] = useState<ProjectTraceView | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setView(await fetchProjectTrace(projectId));
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : '加载失败');
}
}, [projectId]);
useEffect(() => {
load();
}, [load]);
if (error) {
return <div style={boxStyle}>需求与影响暂时不可用:{error}</div>;
}
if (!view) return null;
return (
<div style={boxStyle}>
<div style={{ fontWeight: 600, marginBottom: 6 }}>需求与影响</div>
<div style={{ color: 'var(--text-muted)' }}>
已索引 {view.index.length} 份文档 · 条目 {view.items.length}
{view.hidden_count > 0 ? ` · 另有 ${view.hidden_count} 条不可见` : ''}
</div>
{view.recheck_marks.length > 0 && (
<ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
{view.recheck_marks.map((mark) => (
<li key={mark.id}>
阶段 {mark.stage_key} 待复核({mark.reason || mark.source_doc_id})
<button
type="button"
style={{ marginLeft: 8 }}
onClick={() => {
clearRecheckMark(projectId, mark.id, 'no_change').then(load).catch((err: unknown) => {
setError(err instanceof Error ? err.message : '关闭失败');
});
}}
>
无需变更
</button>
</li>
))}
</ul>
)}
{view.items.length > 0 && (
<ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
{view.items.slice(0, 8).map((item) => (
<li key={item.id}>
{item.business_key}
{item.statement ? ` · ${item.statement}` : ''}
</li>
))}
</ul>
)}
</div>
);
};
const boxStyle: React.CSSProperties = {
margin: '8px 12px 0',
padding: '10px 12px',
borderRadius: 8,
border: '1px solid var(--border, #e2e8f0)',
background: 'var(--bg-subtle, #f8fafc)',
fontSize: 12,
};
@@ -0,0 +1,10 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
test('trace panel is not mounted above the chat stream', () => {
const app = readFileSync(new URL('../../App.tsx', import.meta.url), 'utf8');
assert.equal(app.includes('ProjectTracePanel'), false);
const card = readFileSync(new URL('../chat/InteractiveCard.tsx', import.meta.url), 'utf8');
assert.equal(card.includes("case 'RECHECK_NOTICE'"), true);
});
+27
View File
@@ -1,4 +1,5 @@
import type { Dispatch, SetStateAction } from 'react';
import { clearRecheckMark } from '../api/projectTrace';
import {
fetchIntentPlan,
submitIntentPlanDecision,
@@ -69,6 +70,32 @@ export function useCardActions(input: CardActionsInput) {
}
}
if (!targetCard) return;
if (targetCard.cardType === 'RECHECK_NOTICE' && actionId === 'recheck_no_change') {
const projectId = Number(payload?.project_id || currentProjectId);
const markId = Number(payload?.mark_id);
const serverId = messages.find((m) => m.interactiveCard?.cardId === cardId)?.serverId;
void clearRecheckMark(projectId, markId, 'no_change', serverId)
.then(() => {
setMessages((prev) => resolveCardInMessages(prev, cardId, '已标记为无需变更', currentUser.name));
})
.catch(() => {
setMessages((prev) => [
...prev,
{
id: 'recheck-fail-' + Date.now(),
sessionId: activeSessionId,
role: 'assistant',
authorName: '项目助手',
content: '没能关闭这条待复核,请稍后再试。',
timestamp: new Date().toTimeString().substring(0, 5),
},
]);
});
return;
}
if (isPlanConfirmCard(targetCard, payload)) {
const planId = String(payload?.plan_id || targetCard?.planId || '');
const expectedRevision = Number(payload?.expected_revision || targetCard?.planRevision || 0);
+2 -1
View File
@@ -73,7 +73,8 @@ export type InteractiveCardType =
| 'PLAN_CONFIRM'
| 'SLOT_FILLING'
| 'DIFF_PROPOSAL'
| 'REMINDER_NOTICE';
| 'REMINDER_NOTICE'
| 'RECHECK_NOTICE';
export type CardActionStatus =
| 'active'