From 4882e0ae2526285990250df364dedc3b09f96354 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Thu, 22 Oct 2026 05:57:33 +0000 Subject: [PATCH] agent-exec transport a9ccb6178c1cd1ffb0e66f9ad77b6b65/c2d99ab92f51 --- src/components/Conversation.tsx | 12 +-- src/components/RdmAuditWorkspace.test.tsx | 79 ++++++++++++++- src/components/RdmAuditWorkspace.tsx | 114 ++++++++++++---------- src/components/Workbench.tsx | 21 +++- src/store.ts | 12 ++- src/workspaces.ts | 15 +++ 6 files changed, 183 insertions(+), 70 deletions(-) diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index 24e60a8..5e23bb9 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -9,7 +9,7 @@ import { liveToMessage, livePhaseLabel, runEventsToTimeline, showProcessInline } import { deriveShellPhase, isShellBusy, showLiveBubble, type ShellPhase } from '../runstate' import type { Message, Part, HITLCard, ActivityCard, Artifact } from '../contracts' import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui' -import { docBridge, pendingConfirmations } from './Workbench' +import { docBridge, openPendingConfirmation, pendingConfirmations } from './Workbench' import { splitTimeline, mergeProcessTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts' import { ProcessTimeline } from './ProcessTimeline' import { ConversationWork } from './ConversationWork' @@ -569,7 +569,6 @@ function ConversationSession() { const bgRunning = runs.filter(r => r.kind === 'subagent' && !isTerminal(r.status)).length const unreadResults = cards.filter(card => card.work?.result && card.work.resultVersion > card.work.readVersion) const pendingHere = pendingConfirmations(cards, currentConvId, currentProjectId) - const pendingCardId = cards.find(card => card.conversationId === currentConvId && card.projectId === currentProjectId && card.status === 'action_required')?.id ?? '' useEffect(() => { if (!stickRef.current) return @@ -731,12 +730,9 @@ function ConversationSession() {
- {!workPaneOwnsCards && pendingHere > 0 && } {unreadResults.length > 0 && `) + + it('默认收起:触发钮由 Collapsible 渲染,详情未挂载(不发起读取)', () => { + const html = row() + expect(html).toMatch(trigger(false, '查看证据:笔记切片 SL-9')) + expect(html).toMatch(trigger(false, '查看影响分析 IA-1')) + expect(html).not.toContain('btn v-ghost rdm-audit-wrap') + expect(html).not.toContain('加载中…') + }) + + it('展开态按稳定 id 记忆:重挂载后恢复展开并挂载详情', () => { + setPersistedOpen('rdm-audit-evidence:p1/slice/SL-9', true) + setPersistedOpen('rdm-audit-impact:p1/IA-1', true) + try { + const html = row() + expect(html).toMatch(trigger(true, '查看证据:笔记切片 SL-9')) + expect(html).toMatch(trigger(true, '查看影响分析 IA-1')) + expect(html.match(/加载中…/g)).toHaveLength(2) + } finally { + setPersistedOpen('rdm-audit-evidence:p1/slice/SL-9', false) + setPersistedOpen('rdm-audit-impact:p1/IA-1', false) + } + }) +}) + +describe('审计记录页签能力门禁', () => { + const enabled = (ids: readonly string[]) => ids.map(id => ({ id, enabled: true })) + + it('五条 W8b/W5 命令齐备且启用才就绪;缺一、停用或清单不可用均不就绪', () => { + expect(RDM_AUDIT_OPERATIONS_COMMANDS).toHaveLength(5) + const all = enabled(RDM_AUDIT_OPERATIONS_COMMANDS) + expect(rdmAuditOperationsReady(all)).toBe(true) + for (const missing of RDM_AUDIT_OPERATIONS_COMMANDS) { + expect(rdmAuditOperationsReady(all.filter(c => c.id !== missing))).toBe(false) + expect(rdmAuditOperationsReady(all.map(c => (c.id === missing ? { ...c, enabled: false } : c)))).toBe(false) + } + expect(rdmAuditOperationsReady(null)).toBe(false) + }) + + it('只有 rdm.inspect.* 七命令的构建上不就绪', () => { + expect(rdmAuditOperationsReady(enabled(RDM_AUDIT_COMMANDS))).toBe(false) + }) + + it('能力缺失时不暴露「审计记录」页签,其余页签不受影响', () => { + expect(visibleAuditTabs(false).map(t => t.label)).toEqual(['概览', '资料与文档', '关联追溯', '操作记录']) + expect(visibleAuditTabs(true).map(t => t.label)).toEqual(['概览', '资料与文档', '关联追溯', '操作记录', '审计记录']) + }) +}) + describe('TraceCoverageView 部分覆盖', () => { it('候选不计正式覆盖;缺项与待复核提示部分覆盖', () => { const html = renderToStaticMarkup( { expect(pendingConfirmations(cards, 'v1', 'p2')).toBe(0) }) + it('右栏收起后的确认路径经 openInbox 定位首张待处理卡,与计数同一谓词(含决议开放的工作卡)', () => { + const openInbox = vi.spyOn(store, 'openInbox').mockImplementation(() => {}) + try { + const decisionOpen = { resultVersion: 1, readVersion: 1, decision: { mode: 'single' as const, state: 'open' as const } } + const cards = [ + card({ id: 'dismissed', status: 'dismissed' }), + card({ id: 'elsewhere', conversationId: 'other' }), + card({ id: 'decision', status: 'active', work: decisionOpen }), + card({ id: 'confirm' }), + ] + expect(pendingConfirmations(cards, 'v1', 'p1')).toBe(2) + openPendingConfirmation(cards, 'v1', 'p1') + expect(openInbox).toHaveBeenLastCalledWith('decision') + openPendingConfirmation(cards.filter(c => c.id !== 'decision'), 'v1', 'p1') + expect(openInbox).toHaveBeenLastCalledWith('confirm') + } finally { + openInbox.mockRestore() + } + }) + it('依据条列出来源与 token 摘要,不含正文', () => { const html = renderToStaticMarkup( t.id !== 'operations' || operationsAvailable) +} + const OPERATION_FILTERS = [ ['', '全部操作'], ['doc.instantiate', '创建文档实例'], @@ -107,6 +112,8 @@ export function RdmAuditWorkspace() { const project = caps?.projects.find(p => p.projectId === pid) const exportOn = useStore(s => s.rdmExportAvailable) + const tabs = visibleAuditTabs(useStore(s => s.rdmAuditOperationsAvailable)) + const activeTab: TabId = tabs.some(t => t.id === tab) ? tab : 'summary' if (error) return if (!caps || capsIdentity !== identity) return
加载中…
if (caps.projects.length === 0) return 无可见研发项目——项目创建者与域管理员可查看追溯审计 @@ -123,18 +130,18 @@ export function RdmAuditWorkspace() { {caps.crossProject && 跨项目}
- {TABS.map(t => ( - ))}
- {tab === 'summary' && } - {tab === 'materials' && } - {tab === 'trace' && } - {tab === 'timeline' && } - {tab === 'operations' && } + {activeTab === 'summary' && } + {activeTab === 'materials' && } + {activeTab === 'trace' && } + {activeTab === 'timeline' && } + {activeTab === 'operations' && }
) @@ -1181,32 +1188,33 @@ function EvidenceList({ pid, refs }: { pid: string; refs: RdmOperationRefs }) { ) } -/** 证据跳转:按需读取对象详情;服务端按当前正文权判定,无权时只给出可读拒绝,不假设有正文。 */ +/** 证据跳转:展开时才读取对象详情;服务端按当前正文权判定,无权时只给出可读拒绝,不假设有正文。 */ function EvidenceLink({ pid, evidence }: { pid: string; evidence: RdmOperationRef }) { - const [open, setOpen] = useState(false) + const label = `${evidence.kind.label} ${evidence.id}` + return ( + {`查看证据:${label}`}}> + + + ) +} + +function EvidenceDetail({ pid, evidence, label }: { pid: string; evidence: RdmOperationRef; label: string }) { const [detail, setDetail] = useState(null) const [err, setErr] = useState('') const genRef = useRef(0) useEffect(() => { - if (!open) return setDetail(null); setErr('') return startLatestFetch(genRef, () => api.rdmCommand('rdm.inspect.object', { projectId: pid, kind: evidence.kind.key, id: evidence.id }), setDetail, setErr) - }, [open, pid, evidence.kind.key, evidence.id]) - const label = `${evidence.kind.label} ${evidence.id}` - return ( -
- - {open && (err - ? (/权限拒绝|无权|受限/.test(err) - ?
无权查看该证据(跨项目、无权或已撤权),仅保留元数据:{label}
- : ) - : !detail ?
加载中…
: )} -
- ) + }, [pid, evidence.kind.key, evidence.id]) + if (err) { + return /权限拒绝|无权|受限/.test(err) + ?
无权查看该证据(跨项目、无权或已撤权),仅保留元数据:{label}
+ : + } + return !detail ?
加载中…
: } const refKey = (r: RdmObjectRef) => `${r.kind}:${r.id}` @@ -1234,9 +1242,19 @@ function ImpactSnapshotView({ title, a }: { title: string; a: RdmImpactAnalysis ) } -/** 当时与当前影响:读取不可变快照;重算须显式触发(生成新分析快照,不覆盖历史)。 */ +/** 当时与当前影响:展开时才读取不可变快照;重算须显式触发(生成新分析快照,不覆盖历史)。 */ function ImpactAnalysisBlock({ pid, analysisId }: { pid: string; analysisId: string }) { - const [open, setOpen] = useState(false) + return ( +
+ {`查看影响分析 ${analysisId}`}}> + + +
+ ) +} + +function ImpactAnalysisDetail({ pid, analysisId }: { pid: string; analysisId: string }) { const [snapshot, setSnapshot] = useState(null) const [current, setCurrent] = useState(null) const [err, setErr] = useState('') @@ -1244,10 +1262,9 @@ function ImpactAnalysisBlock({ pid, analysisId }: { pid: string; analysisId: str const snapRef = useRef(0) const currentRef = useRef(0) useEffect(() => { - if (!open) return setSnapshot(null); setErr('') return startLatestFetch(snapRef, () => api.rdmImpactGet(pid, analysisId), setSnapshot, setErr) - }, [open, pid, analysisId]) + }, [pid, analysisId]) useEffect(() => () => { currentRef.current++ }, [pid, analysisId]) const revision = Number(snapshot?.start.revision) @@ -1262,30 +1279,23 @@ function ImpactAnalysisBlock({ pid, analysisId }: { pid: string; analysisId: str const diff = snapshot && current ? diffImpact(snapshot, current) : null return ( -
- - {open && ( - <> - {err && } - {!snapshot && !err &&
加载中…
} - {snapshot && } - {snapshot && revision > 0 && ( - - )} - {current && } - {diff && ( -
-
当前新增:{diff.added.length ? diff.added.map(refLabel).join('、') : '无'}
-
当前不再涉及:{diff.removed.length ? diff.removed.map(refLabel).join('、') : '无'}
-
- )} - + <> + {err && } + {!snapshot && !err &&
加载中…
} + {snapshot && } + {snapshot && revision > 0 && ( + )} -
+ {current && } + {diff && ( +
+
当前新增:{diff.added.length ? diff.added.map(refLabel).join('、') : '无'}
+
当前不再涉及:{diff.removed.length ? diff.removed.map(refLabel).join('、') : '无'}
+
+ )} + ) } diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index 11f75d2..f04fb10 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -34,10 +34,21 @@ const scratchInboxCallbacks = { } const inboxCallbacks = { ...scratchInboxCallbacks, onArchive: (c: ActivityCard) => store.cardToProject(c) } -/** 本会话仍待用户处理的卡片数:右栏收起后由对话承接确认路径。 */ -export function pendingConfirmations(cards: ActivityCard[], conversationId: string | null | undefined, projectId: string | null | undefined): number { - return cards.filter(card => card.status !== 'dismissed' && card.conversationId === conversationId && card.projectId === projectId - && (card.status === 'action_required' || card.work?.decision?.state === 'open')).length +type Scope = string | null | undefined + +/** 本会话仍待用户处理的卡片:计数与定位共用这一谓词。 */ +const isPendingConfirmation = (card: ActivityCard, conversationId: Scope, projectId: Scope) => + card.status !== 'dismissed' && card.conversationId === conversationId && card.projectId === projectId + && (card.status === 'action_required' || card.work?.decision?.state === 'open') + +/** 本会话仍待用户处理的卡片数:右栏收起后由对话输入区提示承接确认路径。 */ +export function pendingConfirmations(cards: ActivityCard[], conversationId: Scope, projectId: Scope): number { + return cards.filter(card => isPendingConfirmation(card, conversationId, projectId)).length +} + +/** 右栏收起或不在「工作」页签时的确认路径:经 openInbox 展开右栏、切到工作页签并定位首张待处理卡。 */ +export function openPendingConfirmation(cards: ActivityCard[], conversationId: Scope, projectId: Scope) { + store.openInbox(cards.find(card => isPendingConfirmation(card, conversationId, projectId))?.id) } /** 项目收件箱视图(CF3/D49/D51):项目级卡片 ∪ 其他会话待办(done 不聚合)。 */ @@ -79,7 +90,7 @@ export function Workbench() { const pending = pendingCount(visible) const unread = visible.filter(card => card.work && card.work.resultVersion > card.work.readVersion).length const pendingHere = useStore(s => pendingConfirmations(s.cards, s.currentConvId, s.currentProjectId)) - const collapseHint = pendingHere > 0 ? `收起右栏(${pendingHere} 项待确认会在对话中继续处理)` : '收起右栏' + const collapseHint = pendingHere > 0 ? `收起右栏(${pendingHere} 项待确认可从对话输入区的提示回到工作栏处理)` : '收起右栏' // 离开文件入口时收回全屏展开,避免出现「工作」铺满主区的怪态。 const switchTab = (tab: 'work' | 'files') => store.setUI({ rightTab: tab, previewExpanded: tab === 'files' ? store.getState().ui.previewExpanded : false }) diff --git a/src/store.ts b/src/store.ts index ec2700c..e359d4c 100644 --- a/src/store.ts +++ b/src/store.ts @@ -11,7 +11,7 @@ import { canFlushOutbox, deriveShellPhase } from './runstate' import { flashElement } from './scrollsync' import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme' import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey' -import { cockpitReady, rdmAuditReady, rdmExportReady } from './workspaces' +import { cockpitReady, rdmAuditOperationsReady, rdmAuditReady, rdmExportReady } from './workspaces' import { rdmRolesReady, rdmMembersReady, rdmDutyReady, rdmPermMatrixReady } from './rdmManagement' import { NO_RENDER_CAPABILITIES, renderCapabilities, type RenderCapabilities } from './documentRenderPresentation' @@ -148,6 +148,8 @@ interface AppState { rdmCockpitAvailable: boolean /** 概览导出区:三条 rdm.inspect.export.* 都启用才为 true。不参与七命令就绪门。 */ rdmExportAvailable: boolean + /** 「审计记录」页签(W10):五条 W8b/W5 命令都启用才为 true。不参与七命令就绪门。 */ + rdmAuditOperationsAvailable: boolean /** U6 RDM 管理页能力:命令面齐备才显示对应分区/页签(普通基座零暴露,fail-closed)。 */ rdmRolesAvailable: boolean rdmMembersAvailable: boolean @@ -227,6 +229,7 @@ let state: AppState = { rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, + rdmAuditOperationsAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES, adminSections: [], @@ -659,7 +662,8 @@ export const store = { plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [], - rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, + rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmAuditOperationsAvailable: false, + rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES, connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false, previewTabs: {}, previewActive: {}, previewOpenIntent: 0 }) @@ -1466,6 +1470,7 @@ export const store = { rdmAuditAvailable: rdmAuditReady(cmds), rdmCockpitAvailable: cockpitReady(cmds), rdmExportAvailable: rdmExportReady(cmds), + rdmAuditOperationsAvailable: rdmAuditOperationsReady(cmds), rdmRolesAvailable: rdmRolesReady(cmds), rdmMembersAvailable: rdmMembersReady(cmds), rdmDocReviewAvailable: rdmDutyReady(cmds), @@ -1474,7 +1479,8 @@ export const store = { }) } catch { if (epoch !== identityEpoch) return - set({ rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, + set({ rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmAuditOperationsAvailable: false, + rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES }) } try { diff --git a/src/workspaces.ts b/src/workspaces.ts index 0e9f0d5..f51e1f5 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -54,6 +54,21 @@ export function rdmAuditReady(cmds: { id?: string; enabled?: boolean }[] | null return RDM_AUDIT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true)) } +/** 「审计记录」页签(W10)消费的 W8b/W5 命令:操作记录列表、受控覆盖面板、影响分析快照与重算。不并入七命令就绪门。 */ +export const RDM_AUDIT_OPERATIONS_COMMANDS = [ + 'rdm.audit.operations', + 'rdm.trace.coverage', + 'rdm.trace.pending-recheck', + 'rdm.compliance.impact.get', + 'rdm.compliance.impact.recompute', +] as const + +/** 五命令齐备且 enabled 才展示「审计记录」页签(能力缺失即不暴露,避免进入即 400)。 */ +export function rdmAuditOperationsReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean { + if (!Array.isArray(cmds)) return false + return RDM_AUDIT_OPERATIONS_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true)) +} + /** 文档驾驶舱消费的全部 rdm.cockpit.* 命令(I1 只读面;rollout I2)。 */ export const COCKPIT_COMMANDS = [ 'rdm.cockpit.overview',