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:
@@ -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={() => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -73,7 +73,8 @@ export type InteractiveCardType =
|
||||
| 'PLAN_CONFIRM'
|
||||
| 'SLOT_FILLING'
|
||||
| 'DIFF_PROPOSAL'
|
||||
| 'REMINDER_NOTICE';
|
||||
| 'REMINDER_NOTICE'
|
||||
| 'RECHECK_NOTICE';
|
||||
|
||||
export type CardActionStatus =
|
||||
| 'active'
|
||||
|
||||
Reference in New Issue
Block a user